Skip to content
Snippets Groups Projects
Commit 38ecad5c authored by florimondmanca's avatar florimondmanca
Browse files

add email notification when changing project participation state

parent 225bf3cb
Branches
No related tags found
1 merge request!4Release version ready to welcome first users
Showing
with 402 additions and 8 deletions
......@@ -35,6 +35,7 @@ DJANGO_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.staticfiles',
'django.forms',
'django.contrib.sites',
]
THIRD_PARTY_APPS = [
......@@ -60,6 +61,7 @@ THIRD_PARTY_APPS = [
# Easy filtering on the API
'django_filters',
]
PROJECT_APPS = [
'core.apps.CoreConfig',
'users.apps.UsersConfig',
......@@ -72,8 +74,14 @@ PROJECT_APPS = [
'dynamicforms.apps.DynamicformsConfig',
'projects.apps.ProjectsConfig',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS
# Activate the sites framework
# It is used to define the domain of the frontend website in
# the admin (via the 'Sites' section)
SITE_ID = 1
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware',
......
......@@ -9,6 +9,7 @@ from django.contrib.auth.models import Group
from tutoring.factory import TutoringGroupFactory
from tutoring.models import TutoringGroup
from core.factory import AddressFactory
from users.factory import UserFactory
from . import models
......@@ -49,6 +50,7 @@ class TutorFactory(factory.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
promotion = factory.Iterator([_this_year, _this_year + 1, _this_year + 2])
address = factory.SubFactory(AddressFactory)
class TutorInGroupFactory(TutorFactory):
......
"""Projects models."""
from django.db import models
from django.contrib.sites.models import Site
from django.core.validators import ValidationError
from markdownx.models import MarkdownxField
......@@ -82,6 +83,14 @@ class Edition(models.Model):
verbose_name = 'édition'
get_latest_by = 'year'
def get_projects_site_url(self) -> str:
site = Site.objects.get_current()
return f'http://{site.domain}/projets/'
def get_registration_url(self) -> str:
site = Site.objects.get_current()
return f'http://{site.domain}/projets/mes-inscriptions'
def __str__(self) -> str:
"""Represent using the project name, the year and the edition name."""
s = f'{self.project} édition {self.year}'
......@@ -219,6 +228,16 @@ class Participation(models.Model):
class Meta: # noqa
ordering = ('-submitted',)
def __init__(self, *args, **kwargs):
"""Store the initial value of `state` to detect changes."""
super().__init__(*args, **kwargs)
self.initial_state = self.state
@property
def state_changed(self) -> bool:
"""Return whether the `state` field has changed."""
return self.initial_state != self.state
def __str__(self):
"""Represent by its user."""
return str(self.user)
......
"""Projects notifications."""
from mails import Notification
from django.utils.timezone import now
from users.models import User
from .models import Edition, Project
class _BaseParticipationNotification(Notification):
"""Base notification for project participations."""
args = ('user', 'edition',)
@classmethod
def example(cls):
"""Example notification."""
user = User(email='john.doe@example.com', first_name='John')
project = Project(title='Focus Europe')
edition = Edition(project=project, year=now().year)
return cls(user=user, edition=edition)
class _NotifyOrgnizers(_BaseParticipationNotification):
"""Notify the edition organizers a participation has changed."""
title: str
def get_subject(self):
return f'{self.title}: {self.edition}'
def get_recipients(self):
"""Return the email of each organizer."""
edition = self.kwargs['edition']
# TODO add the project team's email
return list(edition.values_list('organizers__email', flat=True))
class _NotifyUser(_BaseParticipationNotification):
"""Notify a user their participation state has changed."""
verb: str
def get_subject(self):
return f'Dossier {self.verb}: {self.edition}'
def get_recipients(self):
return [self.kwargs['user'].email]
class OrganizersReceived(_NotifyOrgnizers):
"""Notify the edition organizers that a new participation was created."""
title = 'Nouvelle participation'
template_name = 'projects/organizers_participation_received.md'
class UserReceived(_NotifyUser):
"""Notify a user their participation was well received."""
verb = 'en attente'
template_name = 'projects/participation_received.md'
class UserValid(_NotifyUser):
"""Notify a user their participation was marked as valid."""
verb = 'validé'
template_name = 'projects/participation_valid.md'
class UserAccepted(_NotifyUser):
"""Notify a user their participation was marked as accepted."""
verb = 'accepté'
template_name = 'projects/participation_accepted.md'
class UserRejected(_NotifyUser):
"""Notify a user their participation was marked as rejected."""
verb = 'rejeté'
template_name = 'projects/participation_rejected.md'
class UserCancelled(_NotifyUser):
"""Notify a user their participation was correctly cancelled."""
verb = 'annulé'
template_name = 'projects/participation_cancelled.md'
class UserDeleted(_NotifyUser):
"""Notify a user their participation was correctly deleted."""
verb = 'supprimé'
template_name = 'projects/participation_deleted.md'
class OrganizersCancelled(_NotifyOrgnizers):
"""Notify organizers that a user has cancelled their participation."""
title = 'Participation annulée'
template_name = 'projects/organizers_participation_cancelled.md'
class OrganizersDeleted(_NotifyOrgnizers):
"""Notify organizers a user has deleted their participation."""
title = 'Participation supprimée'
template_name = 'projects/organizers_participation_deleted.md'
"""Projects app signals."""
import logging
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from .models import Participation
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver, Signal
from . import notifications
from .models import Participation
logger = logging.getLogger('web.projects.signals')
pending = Signal(providing_args=('instance',))
valid = Signal(providing_args=('instance',))
accepted = Signal(providing_args=('instance',))
rejected = Signal(providing_args=('instance',))
cancelled = Signal(providing_args=('instance',))
deleted = Signal(providing_args=('instance',))
deleted_organizers = Signal(providing_args=('instance',))
def _send(cls, instance: Participation):
cls(user=instance.user, edition=instance.edition).send()
@receiver(pre_delete, sender=Participation)
def delete_associated_form_entry(sender, instance: Participation,
*args, **kwargs):
def delete_associated_form_entry(sender, instance: Participation, **kwargs):
"""Delete the form entry associated to a participation being deleted."""
entry = instance.entry
if entry:
entry.delete()
logger.info('entry %s deleted', entry.id)
@receiver(post_save, sender=Participation)
def send_state_notifications(sender, instance: Participation,
created, **kwargs):
"""Send notifications when the state of a participation has changed."""
if not created and not instance.state_changed:
return
signals = {
Participation.STATE_PENDING: pending,
Participation.STATE_VALIDATED: valid,
Participation.STATE_ACCEPTED: accepted,
Participation.STATE_REJECTED: rejected,
Participation.STATE_CANCELLED: cancelled,
}
if instance.state in signals.keys():
signals[instance.state].send(Participation, instance=instance)
@receiver(pre_delete, sender=Participation)
def send_participation_deleted_notifications(sender, instance: Participation,
**kwargs):
"""Send notifications when a participation is deleted."""
deleted.send(Participation, instance=instance)
# Notiication senders
@receiver(pending)
def notify_pending(sender, instance, **kwargs):
print('hello')
_send(notifications.UserReceived, instance)
_send(notifications.OrganizersReceived, instance)
@receiver(valid)
def notify_valid(sender, instance, **kwargs):
_send(notifications.UserValid, instance)
@receiver(accepted)
def notify_accepted(sender, instance, **kwargs):
_send(notifications.UserAccepted, instance)
@receiver(rejected)
def notify_rejected(sender, instance, **kwargs):
_send(notifications.UserRejected, instance)
@receiver(cancelled)
def notify_cancelled(sender, instance, **kwargs):
_send(notifications.UserCancelled, instance)
_send(notifications.OrganizersCancelled, instance)
@receiver(deleted)
def notify_deleted(sender, instance, **kwargs):
_send(notifications.UserDeleted, instance)
_send(notifications.OrganizersDeleted, instance)
{% extends 'projects/to_user.md' %}
{% block body %}
L'utilisateur {{ user }} a supprimé sa participation à {{ edition }}.
Si besoin, vous pouvez contacter {{ user }} via son adresse email : {{ user.email }}.
Vous pouvez télécharger la feuille des inscrits mise à jour sur [le site d'administration]({% url 'admin:projects_editionform_changelist' %}).
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
L'utilisateur {{ user }} a annulé sa participation à {{ edition }}.
Si besoin, vous pouvez contacter {{ user }} via son adresse email : {{ user.email }}.
Vous pouvez télécharger la feuille des inscrits mise à jour sur [le site d'administration]({% url 'admin:projects_editionform_changelist' %}).
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
{{ user }} s'est inscrit à {{ edition }}.
Vous pouvez télécharger la feuille des inscrits mise à jour sur [le site d'administration]({% url 'admin:projects_editionform_changelist' %}).
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Nous avons le plaisir de te confirmer ta participation à {{ edition }} ! 🎉
Nous te recontacterons très prochainement pour te communiquer les derniers détails pratiques.
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Ta participation à {{ edition }} a bien été annulée.
Si tu souhaites réactiver ta demande de participation, tu peux le faire
avant le {{ edition.edition_form.deadline | date }} dans la section
[Mes inscriptions]({{ edition.get_registration_url }}).
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Ta participation à {{ edition }} a bien été supprimée.
Si tu souhaites finalement participer à ce projet, tu devras te réinscrire en te rendant sur [l'espace projets]({{ edition.get_projects_site_url }}).
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Tu as demandé t'inscrire à {{ edition }} via l'espace projets.
Nous avons bien reçu ta demande et allons vérifier ton dossier dès que possible.
{% if edition.edition_form.form.files.count %}
**Rappel** : l'inscription à ce projet nécessite de fournir des documents complémentaires. 📖 Tu devras nous les faire parvenir **impérativement avant le {{ edition.edition_form.deadline | date }}** à l'adresse suivante :
**{{ edition.edition_form.recipient.user.get_full_name }}**
{{ edition.edition_form.recipient.address }}
🔗 Tu peux télécharger ces documents à tout moment en te rendant dans la section [Mes inscriptions]({{ edition.get_registration_url }}).
⚠️ Nous ne pourrons valider ton dossier qu'une fois ces documents reçus.
{% endif %}
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Nous avons le regret de t'annoncer qu'en raison du nombre de places limité,
nous n'avons pas pu retenir ta demande de participation à {{ edition }}. 😔
Ne t'en fais pas ! Tu as été placé sur la liste d'attente et
nous te recontacterons si des places se libèrent suite à des désistements.
{% endblock %}
{% extends 'projects/to_user.md' %}
{% block body %}
Nous venons de valider ton dossier pour {{ edition }} : celui-ci est bien complet ! ✅
Une fois la période des inscriptions terminée, nous étudierons chaque demande
avec soin pour établir la liste des inscrits. Cela se fera à partir du {{ edition.edition_form.deadline | date }}.
Nous te recontacterons alors pour te confirmer ta participation.
{% endblock %}
{% extends 'mails/notification.md' %}
{% block greeting %}
Bonjour{% if participation.user.first_name %} {{ participation.user.first_name }}{% endif %},
{% endblock %}
{% block signature %}
À très bientôt,
Les organisateurs
{% endblock %}
"""Test the projects app signals."""
from django.test import TestCase
from django.utils.timezone import now
from tests.utils.mixins import SignalTestMixin
from mails.signals import notification_sent
from dynamicforms.models import Form
from profiles.factory import TutorFactory
from projects.factory import (EditionFactory, ParticipationFactory,
ProjectFactory)
from projects.models import EditionForm, Participation
from projects.signals import (accepted, cancelled, deleted, pending, rejected,
valid)
class NotifyParticipationTest(SignalTestMixin, TestCase):
"""Test project participation signal handlers."""
def setUp(self):
# Create all objects that need to exist for rendering emails
project = ProjectFactory.create(name='Focus Europe')
self.edition = EditionFactory.create(project=project, year=2018)
recipient = TutorFactory.create()
form = Form.objects.create(title=f'Inscriptions à {self.edition}')
EditionForm.objects.create(
edition=self.edition,
form=form,
recipient=recipient,
deadline=now(),
)
self.obj = self.create_obj()
def create_obj(self):
return ParticipationFactory.create(edition=self.edition)
def change(self, state):
self.obj.state = state
self.obj.save()
def test_create_participation_pending_called(self):
with self.assertCalled(pending):
self.create_obj()
def test_save_without_changing_state_not_called(self):
with self.assertNotCalled(pending):
self.obj.save()
def test_valid_called(self):
with self.assertCalled(valid):
self.change(Participation.STATE_VALIDATED)
def test_accepted_called(self):
with self.assertCalled(accepted):
self.change(Participation.STATE_ACCEPTED)
def test_rejected_called(self):
with self.assertCalled(rejected):
self.change(Participation.STATE_REJECTED)
def test_cancelled_called(self):
with self.assertCalled(cancelled):
self.change(Participation.STATE_CANCELLED)
def test_deleted_called(self):
with self.assertCalled(deleted):
self.obj.delete()
......@@ -4,7 +4,6 @@ from django.test import TestCase
from tests.utils.mixins import SignalTestMixin
from mails.signals import notification_sent
from users.factory import UserFactory
from visits.factory import ParticipationFactory, VisitFactory
from visits.notifications import ConfirmParticipation
from visits.signals import accepted_changed
......
......@@ -52,7 +52,7 @@ class SignalTestMixin:
Pass `sender` to check the signal's sender too.
"""
called = {'value': None}
called = {'value': False}
def listen(sender, **kwargs):
called['value'] = True
......@@ -65,3 +65,17 @@ class SignalTestMixin:
self.assertTrue(called['value'])
if 'sender' in kwargs:
self.assertEqual(called['sender'], kwargs['sender'])
@contextmanager
def assertNotCalled(self, signal):
"""Verify that a signal is NOT called."""
called = {'value': False}
def listen(sender, **kwargs):
called['value'] = True
signal.connect(listen)
yield
signal.disconnect(listen)
self.assertFalse(called['value'])
"""Visits models."""
from django.db import models
from django.contrib.sites.models import Site
from django.shortcuts import reverse
from django.utils.timezone import now
from dry_rest_permissions.generics import authenticated_users
......@@ -214,7 +215,8 @@ class Visit(models.Model):
return reverse('api:visit-detail', args=[str(self.pk)])
def get_site_url(self):
return f'http://oser-cs.fr/visits/{self.pk}'
site = Site.objects.get_current()
return f'http://{site.domain}/visits/{self.pk}'
def __str__(self):
return str(self.title)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment