Send emails when proposals are accepted/rejected, and when eventinstances are created/updated (#466)

* fixup labels and help_text for the email field

* add email and published columns to speaker proposal table, add published column to event proposal table

* send email to user when a speaker or eventproposal is accepted or rejected. Send email to submitter and all speakers when an eventinstance is created, or when the time is updated (event is rescheduled). Fix a few other small things while here.
This commit is contained in:
Thomas Steen Rasmussen 2020-03-05 12:23:42 +01:00 committed by GitHub
parent 7e2981e855
commit bf7578a833
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 344 additions and 15 deletions

View file

@ -69,7 +69,7 @@
<td>{{ proposal.title }}</td>
<td>{{ proposal.track }}</td>
<td><i class="fas fa-{{ proposal.event_type.icon }} fa-lg" style="color: {{ proposal.event_type.color }};"></i> {{ proposal.event_type }}</td>
<td>{% for speaker in proposal.speakers.all %}<i class="fas fa-user" data-toggle="tooltip" title="{{ speaker.name }}"></i> {% endfor %}</td>
<td>{% for speaker in proposal.speakers.all %}<i class="fas fa-user{% if speaker.proposal_status == "approved" %} text-success{% elif speaker.proposal_status == "rejected" %} text-danger{% endif %}" data-toggle="tooltip" title="{{ speaker.name }} ({{ speaker.proposal_status }})"></i> {% endfor %}</td>
<td class="text-center">{{ proposal.speaker|truefalseicon }}</td>
<td>{{ proposal.user }}</td>
<td><a href="{% url 'backoffice:eventproposal_manage' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-cog"></i> Manage</a></td>

View file

@ -230,7 +230,7 @@ class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateVi
This class contains the shared logic between SpeakerProposalManageView and EventProposalManageView
"""
fields = []
fields = ["reason"]
def form_valid(self, form):
"""

View file

@ -1,18 +1,24 @@
from django.apps import AppConfig
from django.db.models.signals import m2m_changed, pre_save
from django.db.models.signals import m2m_changed, post_save, pre_save
class ProgramConfig(AppConfig):
name = "program"
def ready(self):
from .models import Speaker
from .models import Speaker, EventInstance
from .signal_handlers import (
check_speaker_event_camp_consistency,
check_speaker_camp_change,
eventinstance_pre_save,
eventinstance_post_save,
)
m2m_changed.connect(
check_speaker_event_camp_consistency, sender=Speaker.events.through
)
pre_save.connect(check_speaker_camp_change, sender=Speaker)
pre_save.connect(eventinstance_pre_save, sender=EventInstance)
post_save.connect(eventinstance_post_save, sender=EventInstance)

View file

@ -1,7 +1,6 @@
import logging
from django.core.exceptions import ObjectDoesNotExist
from teams.models import Team
from utils.email import add_outgoing_email
@ -78,3 +77,66 @@ def add_eventproposal_updated_email(eventproposal):
formatdict=formatdict,
subject="Event proposal '%s' was just updated" % eventproposal.title,
)
def add_speakerproposal_rejected_email(speakerproposal):
formatdict = {"proposal": speakerproposal}
return add_outgoing_email(
text_template="emails/speakerproposal_rejected.txt",
html_template="emails/speakerproposal_rejected.html",
to_recipients=speakerproposal.user.email,
formatdict=formatdict,
subject=f"Your {speakerproposal.camp.title} speaker proposal '{speakerproposal.name}' was rejected",
)
def add_speakerproposal_accepted_email(speakerproposal):
formatdict = {"proposal": speakerproposal}
return add_outgoing_email(
text_template="emails/speakerproposal_accepted.txt",
html_template="emails/speakerproposal_accepted.html",
to_recipients=speakerproposal.user.email,
formatdict=formatdict,
subject=f"Your {speakerproposal.camp.title} speaker proposal '{speakerproposal.name}' was accepted",
)
def add_eventproposal_rejected_email(eventproposal):
formatdict = {"proposal": eventproposal}
return add_outgoing_email(
text_template="emails/eventproposal_rejected.txt",
html_template="emails/eventproposal_rejected.html",
to_recipients=eventproposal.user.email,
formatdict=formatdict,
subject=f"Your {eventproposal.camp.title} event proposal '{eventproposal.title}' was rejected",
)
def add_eventproposal_accepted_email(eventproposal):
formatdict = {"proposal": eventproposal}
return add_outgoing_email(
text_template="emails/eventproposal_accepted.txt",
html_template="emails/eventproposal_accepted.html",
to_recipients=eventproposal.user.email,
formatdict=formatdict,
subject=f"Your {eventproposal.camp.title} event proposal '{eventproposal.title}' was accepted!",
)
def add_event_scheduled_email(eventinstance, action):
formatdict = {"eventinstance": eventinstance, "action": action}
recipients = [speaker.email for speaker in eventinstance.event.speakers.all()]
recipients.append(eventinstance.event.proposal.user.email)
# loop over unique recipients and send an email to each
for rcpt in set(recipients):
return add_outgoing_email(
text_template="emails/event_scheduled.txt",
html_template="emails/event_scheduled.html",
to_recipients=rcpt,
formatdict=formatdict,
subject=f"Your {eventinstance.camp.title} event '{eventinstance.event.title}' has been {action}!",
)

View file

@ -13,11 +13,6 @@ class SpeakerProposalForm(forms.ModelForm):
The SpeakerProposalForm. Takes an EventType in __init__ and changes fields accordingly.
"""
email = forms.EmailField(
required=False,
help_text="The email of the speaker (defaults to the logged in user if empty).",
)
class Meta:
model = SpeakerProposal
fields = [
@ -43,6 +38,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of a debate guest. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Guest Email"
self.fields[
"email"
].help_text = "The email for this guest. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Guest Biography"
self.fields["biography"].help_text = "The biography of the guest."
@ -63,6 +64,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of the speaker. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Speaker Email"
self.fields[
"email"
].help_text = "The email for this speaker. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Speaker Biography"
self.fields["biography"].help_text = "The biography of the speaker."
@ -83,6 +90,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of the artist. Can be a real name or artist alias."
# fix label and help_text for the email field
self.fields["email"].label = "Artist Email"
self.fields[
"email"
].help_text = "The email for this artist. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Artist Description"
self.fields["biography"].help_text = "The description of the artist."
@ -103,6 +116,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of the event host. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Host Email"
self.fields[
"email"
].help_text = "The email for the host. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Host Biography"
self.fields["biography"].help_text = "The biography of the host."
@ -123,6 +142,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of the speaker. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Speaker Email"
self.fields[
"email"
].help_text = "The email for this speaker. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Speaker Biography"
self.fields["biography"].help_text = "The biography of the speaker."
@ -142,6 +167,12 @@ class SpeakerProposalForm(forms.ModelForm):
"The name of the workshop host. Can be a real name or an alias."
)
# fix label and help_text for the email field
self.fields["email"].label = "Host Email"
self.fields[
"email"
].help_text = "The email for the host. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Host Biography"
self.fields["biography"].help_text = "The biography of the host."
@ -160,6 +191,12 @@ class SpeakerProposalForm(forms.ModelForm):
self.fields["name"].label = "Host Name"
self.fields["name"].help_text = "Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Host Email"
self.fields[
"email"
].help_text = "The email for the host. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Host Biography"
self.fields["biography"].help_text = "The biography of the host."
@ -180,6 +217,12 @@ class SpeakerProposalForm(forms.ModelForm):
"name"
].help_text = "The name of the meetup host. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Host Email"
self.fields[
"email"
].help_text = "The email for the host. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Host Biography"
self.fields["biography"].help_text = "The biography of the host."

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-02-26 17:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0082_eventinstance_uuid_not_null"),
]
operations = [
migrations.AddField(
model_name="eventproposal",
name="reason",
field=models.TextField(
blank=True,
help_text="The reason this proposal was accepted or rejected.",
),
),
migrations.AddField(
model_name="speakerproposal",
name="reason",
field=models.TextField(
blank=True,
help_text="The reason this proposal was accepted or rejected.",
),
),
]

View file

@ -0,0 +1,30 @@
# Generated by Django 3.0.3 on 2020-02-29 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0083_auto_20200226_1853"),
]
operations = [
migrations.AlterField(
model_name="eventproposal",
name="allow_video_recording",
field=models.BooleanField(
default=False,
help_text="Recordings are made available under the <b>CC BY-SA 4.0</b> license. Uncheck if you do not want the event recorded, or if you cannot accept the license.",
),
),
migrations.AlterField(
model_name="speakerproposal",
name="email",
field=models.EmailField(
blank=True,
help_text="The email of the speaker/artist/host. Defaults to the logged in users email if empty.",
max_length=150,
),
),
]

View file

@ -14,6 +14,13 @@ from django.urls import reverse, reverse_lazy
from django.utils.text import slugify
from utils.models import CampRelatedModel, CreatedUpdatedModel, UUIDModel
from .email import (
add_eventproposal_accepted_email,
add_eventproposal_rejected_email,
add_speakerproposal_accepted_email,
add_speakerproposal_rejected_email,
)
logger = logging.getLogger("bornhack.%s" % __name__)
@ -175,6 +182,10 @@ class UserSubmittedModel(CampRelatedModel):
max_length=50, choices=PROPOSAL_STATUS_CHOICES, default=PROPOSAL_PENDING
)
reason = models.TextField(
blank=True, help_text="The reason this proposal was accepted or rejected.",
)
def __str__(self):
return "%s (submitted by: %s, status: %s)" % (
self.headline,
@ -214,8 +225,9 @@ class SpeakerProposal(UserSubmittedModel):
)
email = models.EmailField(
blank=True,
max_length=150,
help_text="The email of the speaker (defaults to the logged in user if empty).",
help_text="The email of the speaker/artist/host. Defaults to the logged in users email if empty.",
)
biography = models.TextField(
@ -278,12 +290,14 @@ class SpeakerProposal(UserSubmittedModel):
messages.success(
request, "Speaker object %s has been created/updated" % speaker
)
add_speakerproposal_accepted_email(self)
def mark_as_rejected(self, request):
speakerproposalmodel = apps.get_model("program", "speakerproposal")
self.proposal_status = speakerproposalmodel.PROPOSAL_REJECTED
self.save()
messages.success(request, "SpeakerProposal %s has been rejected" % self.name)
add_speakerproposal_rejected_email(self)
class EventProposal(UserSubmittedModel):
@ -319,7 +333,7 @@ class EventProposal(UserSubmittedModel):
allow_video_recording = models.BooleanField(
default=False,
help_text="Uncheck to avoid video recording. Recordings are made available under the CC BY-SA 4.0 license. Uncheck if you can not accept this license.",
help_text="Recordings are made available under the <b>CC BY-SA 4.0</b> license. Uncheck if you do not want the event recorded, or if you cannot accept the license.",
)
duration = models.IntegerField(
@ -397,12 +411,14 @@ class EventProposal(UserSubmittedModel):
Url.objects.create(url=url.url, urltype=url.urltype, event=event)
messages.success(request, "Event object %s has been created/updated" % event)
add_eventproposal_accepted_email(self)
def mark_as_rejected(self, request):
eventproposalmodel = apps.get_model("program", "eventproposal")
self.proposal_status = eventproposalmodel.PROPOSAL_REJECTED
self.save()
messages.success(request, "EventProposal %s has been rejected" % self.title)
add_eventproposal_rejected_email(self)
###############################################################################

View file

@ -2,6 +2,8 @@ import logging
from django.core.exceptions import ValidationError
from .email import add_event_scheduled_email
logger = logging.getLogger("bornhack.%s" % __name__)
@ -29,8 +31,7 @@ def check_speaker_event_camp_consistency(sender, instance, **kwargs):
if event.camp != instance.camp:
raise ValidationError(
{
"events": "The event (%s) belongs to a different camp (%s) than the event does (%s)"
% (event, event.camp, instance.camp)
"events": f"The event ({event}) belongs to a different camp ({event.camp}) than the speaker does ({instance.camp})"
}
)
@ -44,3 +45,23 @@ def check_speaker_camp_change(sender, instance, **kwargs):
"camp": "You cannot change the camp a speaker belongs to if the speaker is associated with one or more events."
}
)
def eventinstance_pre_save(sender, instance, **kwargs):
""" Save the old instance.when value so we can later determine if it changed """
try:
# get the old instance from the database, if we have one
instance.old_when = sender.objects.get(pk=instance.pk).when
except sender.DoesNotExist:
# nothing found in the DB with this pk, this is a new eventinstance
instance.old_when = instance.when
def eventinstance_post_save(sender, instance, created, **kwargs):
""" Send an email if this is a new eventinstance, or if the "when" field changed """
if created:
add_event_scheduled_email(eventinstance=instance, action="scheduled")
else:
if instance.old_when != instance.when:
# date/time for this eventinstance changed, send a rescheduled email
add_event_scheduled_email(eventinstance=instance, action="rescheduled")

View file

@ -0,0 +1,8 @@
Hello,<br>
<br>
The {{ eventinstance.camp.title }} event "{{ eventinstance.event.title }}" has been {{ action }} to begin {{ eventinstance.when.lower }} and end at {{ eventinstance.when.upper }}.<br>
<br>
Best regards,<br>
<br>
The BornHack Content Team<br>
<br>

View file

@ -0,0 +1,8 @@
Hello,
The {{ eventinstance.camp.title }} event "{{ eventinstance.event.title }}" has been {% if rescheduled %}re{% endif %}scheduled to begin {{ eventinstance.when.lower }} and end at {{ eventinstance.when.upper }}.
Best regards,
The BornHack Content Team

View file

@ -0,0 +1,11 @@
{% load commonmark %}
Hello,<br>
<br>
We have reviewed your event proposal "{{ proposal.title }}" and have decided to accept it.<br>
<br>
{% if proposal.reason %}{{ proposal.reason|untrustedcommonmark }}{% endif %}
<br>
Best regards,<br>
<br>
The BornHack Content Team<br>
<br>

View file

@ -0,0 +1,10 @@
Hello,
We have reviewed your event proposal "{{ proposal.title }}" and have decided to accept it.
{% if proposal.reason %}{{ proposal.reason }}{% endif %}
Best regards,
The BornHack Content Team

View file

@ -0,0 +1,11 @@
{% load commonmark %}
Hello,<br>
<br>
We have reviewed your event proposal "{{ proposal.title }}" and have decided to reject it.<br>
<br>
{% if proposal.reason %}{{ proposal.reason|untrustedcommonmark }}{% endif %}
<br>
Best regards,<br>
<br>
The BornHack Content Team<br>
<br>

View file

@ -0,0 +1,10 @@
Hello,
We have reviewed your event proposal "{{ proposal.title }}" and have decided to reject it.
{% if proposal.reason %}{{ proposal.reason }}{% endif %}
Best regards,
The BornHack Content Team

View file

@ -0,0 +1,11 @@
{% load commonmark %}
Hello,<br>
<br>
We have reviewed your speaker proposal "{{ proposal.name }}" and have decided to accept it.<br>
<br>
{% if proposal.reason %}{{ proposal.reason|untrustedcommonmark }}{% endif %}
<br>
Best regards,<br>
<br>
The BornHack Content Team<br>
<br>

View file

@ -0,0 +1,10 @@
Hello,
We have reviewed your speaker proposal "{{ proposal.name }}" and have decided to accept it.
{% if proposal.reason %}{{ proposal.reason }}{% endif %}
Best regards,
The BornHack Content Team

View file

@ -0,0 +1,11 @@
{% load commonmark %}
Hello,<br>
<br>
We have reviewed your speaker proposal "{{ proposal.name }}" and have decided to reject it.<br>
<br>
{% if proposal.reason %}{{ proposal.reason|untrustedcommonmark }}{% endif %}
<br>
Best regards,<br>
<br>
The BornHack Content Team<br>
<br>

View file

@ -0,0 +1,10 @@
Hello,
We have reviewed your speaker proposal "{{ proposal.name }}" and have decided to reject it.
{% if proposal.reason %}{{ proposal.reason }}{% endif %}
Best regards,
The BornHack Content Team

View file

@ -7,6 +7,7 @@
<th>People</th>
<th>Track</th>
<th>Status</th>
<th>Published</th>
{% if request.resolver_match.app_name == "program" %}
<th class='text-right'>Available Actions</th>
{% endif %}
@ -30,6 +31,13 @@
</span></td>
<td><span class="h4">{{ eventproposal.track.name }}</span></td>
<td><span class="badge">{{ eventproposal.proposal_status }}</span></td>
<td>
{% if eventproposal.event %}
<a href="{% url 'program:event_detail' camp_slug=camp.slug event_slug=eventproposal.event.slug %}" class="btn btn-default btn-sm"><i class="fas fa-{{ eventproposal.event_type.icon }} fa-lg" style="color: {{ eventproposal.event_type.color }};"></i> Show Event</a>
{% else %}
<i class="fas fa-times"></i>
{% endif %}
</td>
{% if request.resolver_match.app_name == "program" %}
<td class='text-right'>
<a href="{% url 'program:eventproposal_detail' camp_slug=camp.slug pk=eventproposal.uuid %}" class="btn btn-primary btn-sm">

View file

@ -2,9 +2,11 @@
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th class="text-center">Events</th>
<th class="text-center">URLs</th>
<th>Status</th>
<th>Published</th>
{% if request.resolver_match.app_name == "program" %}
<th class="text-right">Available Actions</th>
{% endif %}
@ -14,6 +16,7 @@
{% for speakerproposal in speakerproposals %}
<tr>
<td><span class="h4">{{ speakerproposal.name }}</span></td>
<td><span class="h4">{{ speakerproposal.email }}</span></td>
<td class="text-center">
{% if speakerproposal.eventproposals.all %}
{% for ep in speakerproposal.eventproposals.all %}
@ -31,6 +34,13 @@
{% endfor %}
</td>
<td><span class="badge">{{ speakerproposal.proposal_status }}</span></td>
<td>
{% if speakerproposal.speaker %}
<a href="{% url 'program:speaker_detail' camp_slug=camp.slug slug=speakerproposal.speaker.slug %}" class="btn btn-default btn-sm"><i class="fas fa-user"></i> Show Speaker</a>
{% else %}
<i class="fas fa-times"></i>
{% endif %}
</td>
{% if request.resolver_match.app_name == "program" %}
<td class="text-right">
<a href="{% url 'program:speakerproposal_detail' camp_slug=camp.slug pk=speakerproposal.uuid %}" class="btn btn-primary btn-sm">

View file

@ -86,10 +86,14 @@ def add_outgoing_email(
if not isinstance(to_recipients, list):
to_recipients = [to_recipients]
for recipient in to_recipients:
# loop over recipients and validate each
for recipient in to_recipients + cc_recipients + bcc_recipients:
try:
validate_email(recipient)
except ValidationError:
logger.error(
f"There was a problem validating the email {recipient} - returning False"
)
return False
email = OutgoingEmail.objects.create(