diff --git a/oser_backend/settings/common.py b/oser_backend/settings/common.py index 85aadb61e3d8b6251379cbdf43d5223f4cb7b788..1a1b18a429a33d7d47392c1bc3da94de942961c0 100644 --- a/oser_backend/settings/common.py +++ b/oser_backend/settings/common.py @@ -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', diff --git a/profiles/factory.py b/profiles/factory.py index ea03ddd9b733673ce25ad06b25aea18143a7d93c..639c70fdc222f00e863aa590741453282a2283fe 100644 --- a/profiles/factory.py +++ b/profiles/factory.py @@ -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): diff --git a/projects/models.py b/projects/models.py index 876c3519148674c2bd2053275afe0a89190e16dd..dc7d675e8db5f98cbb651cc861b5d1ccd0a89cd3 100644 --- a/projects/models.py +++ b/projects/models.py @@ -1,6 +1,7 @@ """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) diff --git a/projects/notifications.py b/projects/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..2ef5f91831925d4d35efdfeafcb9611481087aa2 --- /dev/null +++ b/projects/notifications.py @@ -0,0 +1,111 @@ +"""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' diff --git a/projects/signals.py b/projects/signals.py index 4b75cea9a475f587c0874e5a2b8582cb55b24124..bea1c4e96bfd551b39aa46dd89e427955f2cb583 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -1,20 +1,93 @@ """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) diff --git a/projects/templates/projects/organizers_participation_cancelled.md b/projects/templates/projects/organizers_participation_cancelled.md new file mode 100644 index 0000000000000000000000000000000000000000..b3c82fdecad51c73ec2c3f4e608823c7e8a233cd --- /dev/null +++ b/projects/templates/projects/organizers_participation_cancelled.md @@ -0,0 +1,9 @@ +{% 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 %} diff --git a/projects/templates/projects/organizers_participation_deleted.md b/projects/templates/projects/organizers_participation_deleted.md new file mode 100644 index 0000000000000000000000000000000000000000..b6648b782fd8f479aa3289e88ba1b086d9505ddf --- /dev/null +++ b/projects/templates/projects/organizers_participation_deleted.md @@ -0,0 +1,9 @@ +{% 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 %} diff --git a/projects/templates/projects/organizers_participation_received.md b/projects/templates/projects/organizers_participation_received.md new file mode 100644 index 0000000000000000000000000000000000000000..e3c715a793a76b6d1acdcab7ca469abc402dbfac --- /dev/null +++ b/projects/templates/projects/organizers_participation_received.md @@ -0,0 +1,7 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_accepted.md b/projects/templates/projects/participation_accepted.md new file mode 100644 index 0000000000000000000000000000000000000000..2774311572aaee752fa24ee787c4da28197ae3b2 --- /dev/null +++ b/projects/templates/projects/participation_accepted.md @@ -0,0 +1,8 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_cancelled.md b/projects/templates/projects/participation_cancelled.md new file mode 100644 index 0000000000000000000000000000000000000000..58eef49ca2a93650c11608d5fb16a4019f019d42 --- /dev/null +++ b/projects/templates/projects/participation_cancelled.md @@ -0,0 +1,9 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_deleted.md b/projects/templates/projects/participation_deleted.md new file mode 100644 index 0000000000000000000000000000000000000000..def88a1caee4dcc3c06f462eaf2cf1743fd7229f --- /dev/null +++ b/projects/templates/projects/participation_deleted.md @@ -0,0 +1,7 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_received.md b/projects/templates/projects/participation_received.md new file mode 100644 index 0000000000000000000000000000000000000000..9cb4bfa2c9114d1ccaa532b049082d9f57c9ff82 --- /dev/null +++ b/projects/templates/projects/participation_received.md @@ -0,0 +1,19 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_rejected.md b/projects/templates/projects/participation_rejected.md new file mode 100644 index 0000000000000000000000000000000000000000..e55a80cc09322d4754e880273f0cc7ec77bc8d66 --- /dev/null +++ b/projects/templates/projects/participation_rejected.md @@ -0,0 +1,9 @@ +{% 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 %} diff --git a/projects/templates/projects/participation_valid.md b/projects/templates/projects/participation_valid.md new file mode 100644 index 0000000000000000000000000000000000000000..f31c3b649cac2fa9ce734ec1ce2e47c832c9d3f8 --- /dev/null +++ b/projects/templates/projects/participation_valid.md @@ -0,0 +1,10 @@ +{% 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 %} diff --git a/projects/templates/projects/to_user.md b/projects/templates/projects/to_user.md new file mode 100644 index 0000000000000000000000000000000000000000..00fc3dffc3de9c18ca092ddeaa54f288df36f031 --- /dev/null +++ b/projects/templates/projects/to_user.md @@ -0,0 +1,11 @@ +{% 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 %} diff --git a/tests/test_projects/test_signals.py b/tests/test_projects/test_signals.py new file mode 100644 index 0000000000000000000000000000000000000000..62e7f10a4d8eaf047e00c60ec936537ffa47dbda --- /dev/null +++ b/tests/test_projects/test_signals.py @@ -0,0 +1,68 @@ +"""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() diff --git a/tests/test_visits/test_signals.py b/tests/test_visits/test_signals.py index 01027bc744db579ed305fc79f5bb90a585fb8133..188bd95bf69c6669e32b1999182c07aeae732141 100644 --- a/tests/test_visits/test_signals.py +++ b/tests/test_visits/test_signals.py @@ -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 diff --git a/tests/utils/mixins.py b/tests/utils/mixins.py index b7cd15e056ef14ec1f40db339caf9ba3b551c101..37de2965a851af517cbfce6ff3420726b565c1e5 100644 --- a/tests/utils/mixins.py +++ b/tests/utils/mixins.py @@ -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']) diff --git a/visits/models.py b/visits/models.py index 62aef12da83f59786fb49b24ebd251169244e56b..e8f73336a2ace397ab3e13e8d3e56244c5b2c70e 100644 --- a/visits/models.py +++ b/visits/models.py @@ -1,5 +1,6 @@ """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)