diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 84960743a..f1569e996 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -404,4 +404,6 @@ class CourseWishAdmin(admin.ModelAdmin): "course", "owner", ) - readonly_fields = ("id",) + readonly_fields = ( + "id", + ) diff --git a/src/backend/joanie/core/apps.py b/src/backend/joanie/core/apps.py index 663e3771c..4b30e2e41 100644 --- a/src/backend/joanie/core/apps.py +++ b/src/backend/joanie/core/apps.py @@ -20,6 +20,21 @@ def ready(self): sender=models.CourseRun, dispatch_uid="save_course_run", ) + post_save.connect( + signals.post_save_course_run_notification, + sender=models.CourseRun, + dispatch_uid="post_save_course_run_notification", + ) + post_save.connect( + signals.post_save_product_target_course_relation_notification, + sender=models.ProductTargetCourseRelation, + dispatch_uid="post_save_product_target_course_relation_notification", + ) + m2m_changed.connect( + signals.post_add_m2m_product_target_course_notification, + sender=models.Product.target_courses.through, + dispatch_uid="post_add_m2m_product_target_course_notification", + ) post_save.connect( signals.on_save_product_target_course_relation, sender=models.ProductTargetCourseRelation, diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index 304078c3c..f2edc37d3 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -6,6 +6,7 @@ from django.conf import settings from django.contrib.auth.hashers import make_password +from django.contrib.contenttypes.models import ContentType from django.utils import timezone as django_timezone import factory.fuzzy @@ -400,3 +401,19 @@ class Meta: course = factory.SubFactory(CourseFactory) owner = factory.SubFactory(UserFactory) + + +class NotificationFactory(factory.django.DjangoModelFactory): + """A factory to create an user wish""" + + class Meta: + model = models.Notification + exclude = ['notif_subject, notif_object'] + + owner = factory.SubFactory(UserFactory) + + notif_subject_id = factory.SelfAttribute('notif_subject.id') + notif_subject_ctype = factory.LazyAttribute(lambda o: ContentType.objects.get_for_model(o.notif_subject)) + + notif_object_id = factory.SelfAttribute('notif_object.id') + notif_object_ctype = factory.LazyAttribute(lambda o: ContentType.objects.get_for_model(o.notif_object)) diff --git a/src/backend/joanie/core/migrations/0003_alter_courserun_is_listed_notification.py b/src/backend/joanie/core/migrations/0003_alter_courserun_is_listed_notification.py new file mode 100644 index 000000000..8a30f6868 --- /dev/null +++ b/src/backend/joanie/core/migrations/0003_alter_courserun_is_listed_notification.py @@ -0,0 +1,43 @@ +# Generated by Django 4.0.10 on 2023-02-24 16:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0002_coursewish'), + ] + + operations = [ + migrations.AlterField( + model_name='courserun', + name='is_listed', + field=models.BooleanField(default=False, help_text='If checked the course run will be included in the list of course runs available for enrollment on the related course page.', verbose_name='is listed'), + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_on', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_on', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('notif_subject_id', models.UUIDField()), + ('notif_object_id', models.UUIDField()), + ('action_type', models.CharField(choices=[('EMAIL', 'Send an email')], default='EMAIL', max_length=10, verbose_name='Type of action')), + ('notified_at', models.DateTimeField(blank=True, editable=False, help_text='date and time at which an email has been sent to the user', null=True, verbose_name='Notified at')), + ('notif_object_ctype', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_ctype_of_notifications', to='contenttypes.contenttype')), + ('notif_subject_ctype', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subject_ctype_of_notifications', to='contenttypes.contenttype')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + 'db_table': 'joanie_notification', + 'ordering': ('owner', 'notified_at'), + }, + ), + ] diff --git a/src/backend/joanie/core/migrations/0004_rename_action_type_notification_notif_type_and_more.py b/src/backend/joanie/core/migrations/0004_rename_action_type_notification_notif_type_and_more.py new file mode 100644 index 000000000..aeb26097b --- /dev/null +++ b/src/backend/joanie/core/migrations/0004_rename_action_type_notification_notif_type_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.10 on 2023-02-27 15:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0003_alter_courserun_is_listed_notification'), + ] + + operations = [ + migrations.RenameField( + model_name='notification', + old_name='action_type', + new_name='notif_type', + ), + migrations.AlterField( + model_name='notification', + name='notif_object_ctype', + field=models.ForeignKey(limit_choices_to=models.Q(models.Q(('app_label', 'core'), ('model', 'producttargetcourserelation')), models.Q(('app_label', 'core'), ('model', 'courserun')), models.Q(('app_label', 'core'), ('model', 'product')), _connector='OR'), on_delete=django.db.models.deletion.CASCADE, related_name='object_ctype_of_notifications', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='notification', + name='notif_subject_ctype', + field=models.ForeignKey(limit_choices_to=models.Q(models.Q(('app_label', 'core'), ('model', 'coursewish')), models.Q(('app_label', 'core'), ('model', 'enrollment')), _connector='OR'), on_delete=django.db.models.deletion.CASCADE, related_name='subject_ctype_of_notifications', to='contenttypes.contenttype'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0005_notification_action_alter_notification_notif_type.py b/src/backend/joanie/core/migrations/0005_notification_action_alter_notification_notif_type.py new file mode 100644 index 000000000..2af77b5df --- /dev/null +++ b/src/backend/joanie/core/migrations/0005_notification_action_alter_notification_notif_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.10 on 2023-02-28 09:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_rename_action_type_notification_notif_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='action', + field=models.CharField(choices=[('ADD', 'added'), ('CREATE', 'created')], default='CREATE', max_length=10, verbose_name='Action on the object of notification'), + ), + migrations.AlterField( + model_name='notification', + name='notif_type', + field=models.CharField(choices=[('EMAIL', 'Send an email')], default='EMAIL', max_length=10, verbose_name='Type of notification'), + ), + ] diff --git a/src/backend/joanie/core/models/__init__.py b/src/backend/joanie/core/models/__init__.py index fba38bbff..6df6b2235 100644 --- a/src/backend/joanie/core/models/__init__.py +++ b/src/backend/joanie/core/models/__init__.py @@ -7,3 +7,4 @@ from .courses import * from .products import * from .wishlist import * +from .notifications import * diff --git a/src/backend/joanie/core/models/base.py b/src/backend/joanie/core/models/base.py index e6b4dd8a7..60908c684 100644 --- a/src/backend/joanie/core/models/base.py +++ b/src/backend/joanie/core/models/base.py @@ -7,6 +7,7 @@ import uuid from itertools import chain +from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models from django.utils.translation import gettext_lazy as _ @@ -52,7 +53,8 @@ def to_dict(self): opts = self._meta data = {} for field in chain(opts.concrete_fields, opts.private_fields): - data[field.name] = field.value_from_object(self) + if not isinstance(field, GenericForeignKey): + data[field.name] = field.value_from_object(self) for field in opts.many_to_many: data[field.name] = [related.id for related in field.value_from_object(self)] return data diff --git a/src/backend/joanie/core/models/notifications.py b/src/backend/joanie/core/models/notifications.py new file mode 100644 index 000000000..1b3cc4265 --- /dev/null +++ b/src/backend/joanie/core/models/notifications.py @@ -0,0 +1,126 @@ +""" +Declare and configure the models for the notification part +""" +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from . import User +from .base import BaseModel + + +class Notification(BaseModel): + """ + Notification model define a notification about an action on a object instance according a + subject instance. + + notif_subject: [GenericForeignKey] + Defined using notif_subject_ctype [ContentType] and notif_subject_id [uuid] + It represents the user's subject that he want to be notified about. + It can be a user course wish or a user course enrollment. + + notif_object: [GenericForeignKey] + Defined using notif_object_ctype [ContentType] and notif_object_id [uuid] + It represents the object that generate the notification according the user's subject + It can be a course run, a product or the relation between a product and an course. + + action: [CharField with NOTIF_ACTIONS choices] + It represents the action done on the object that generate the notification + The action's values are + "ADD": the object has been add in a m2m relationship + "CREATE": the object has been create + + notif_type: [CharField with NOTIF_TYPES choices] + It's the type of the notification + It can be an email or a dashboard notification (not implemented yet) + + notified_at: [DateTimeField] + It's the DatiTime when the notification has been sent to the owner. + If the value is None, the notification has not been sent yet. + + owner: [User] + It's the User that received or will receive the notification. + """ + + NOTIF_ACTION_ADD = "ADD" + NOTIF_ACTION_CREATE = "CREATE" + + NOTIF_ACTIONS = ( + (NOTIF_ACTION_ADD, _("added")), + (NOTIF_ACTION_CREATE, _("created")), + ) + + NOTIF_TYPE_EMAIL = "EMAIL" + + NOTIF_TYPES = ( + (NOTIF_TYPE_EMAIL, _("Send an email")), + ) + + notif_subject_ctype_limit = models.Q(app_label='core', model='coursewish') \ + | models.Q(app_label='core', model='enrollment') + notif_subject_ctype = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="subject_ctype_of_notifications", + limit_choices_to=notif_subject_ctype_limit + ) + notif_subject_id = models.UUIDField() + notif_subject = GenericForeignKey('notif_subject_ctype', 'notif_subject_id') + + notif_object_ctype_limit = models.Q(app_label='core', model='producttargetcourserelation') \ + | models.Q(app_label='core', model='courserun') \ + | models.Q(app_label='core', model='product') + notif_object_ctype = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="object_ctype_of_notifications", + limit_choices_to=notif_object_ctype_limit + ) + notif_object_id = models.UUIDField() + notif_object = GenericForeignKey('notif_object_ctype', 'notif_object_id') + + action = models.CharField( + _("Action on the object of notification"), + choices=NOTIF_ACTIONS, + max_length=10, + default=NOTIF_ACTION_CREATE + ) + + notif_type = models.CharField( + _("Type of notification"), + choices=NOTIF_TYPES, + max_length=10, + default=NOTIF_TYPE_EMAIL + ) + + notified_at = models.DateTimeField( + verbose_name=_("Notified at"), + help_text=_("date and time when the notification has been sent to the owner"), + blank=True, + null=True, + editable=False, + ) + + owner = models.ForeignKey(User, on_delete=models.PROTECT) + + class Meta: + db_table = "joanie_notification" + ordering = ("owner", "notified_at") + verbose_name = _( + "Notification" + ) + verbose_name_plural = _( + "Notifications" + ) + + def __str__(self): + if self.notified_at: + return ( + f"'{self.owner}' has been notified about " + f"'{self.notif_object}' according to '{self.notif_subject}' at '{self.notified_at}'" + ) + return ( + f"'{self.owner}' hasn't been notified about " + f"'{self.notif_object}' according to '{self.notif_subject}' yet" + ) diff --git a/src/backend/joanie/core/models/wishlist.py b/src/backend/joanie/core/models/wishlist.py index 905951511..6bc332ca0 100644 --- a/src/backend/joanie/core/models/wishlist.py +++ b/src/backend/joanie/core/models/wishlist.py @@ -10,7 +10,7 @@ class CourseWish(BaseModel): """ - CourseWish represents and records a user wish to participate at a course + CourseWish represents and records a user wish to participate to a course """ owner = models.ForeignKey( diff --git a/src/backend/joanie/core/signals.py b/src/backend/joanie/core/signals.py index 010f7a086..850f462c6 100644 --- a/src/backend/joanie/core/signals.py +++ b/src/backend/joanie/core/signals.py @@ -3,6 +3,7 @@ """ import logging +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from joanie.core import enums, models @@ -157,3 +158,57 @@ def on_change_course_product_relation(action, instance, pk_set, **kwargs): return webhooks.synchronize_course_runs(serialized_course_runs) + + +def base_create_notification(instance, course_wishes, action=None): + """ + Create a notification instance to each user who has a course_wish to participate to + the course related to the saved instance + """ + for course_wish in course_wishes: + course_wish_ctype = ContentType.objects.get_for_model(course_wish._meta.model) + instance_ctype = ContentType.objects.get_for_model(instance._meta.model) + + notif_data = { + "owner": course_wish.owner, + "notif_subject_ctype": course_wish_ctype, + "notif_subject_id": course_wish.id, + "notif_object_ctype": instance_ctype, + "notif_object_id": instance.id, + } + if action: + notif_data["action"] = action + + models.Notification.objects.get_or_create( + **notif_data + ) + + +def post_save_course_run_notification(instance, created, **kwargs): + """ + Create a notification instance to each user who has a course_wish to participate to + the course of the saved course run. + """ + if created: + course_wishes = instance.course.wished_in_wishlists.all() + base_create_notification(instance, course_wishes, models.Notification.NOTIF_ACTION_CREATE) + + +def post_save_product_target_course_relation_notification(instance, created, **kwargs): + """ + Create a notification instance to each user who has a course_wish to participate to + the course of the saved course run. + """ + if created: + course_wishes = instance.course.wished_in_wishlists.all() + base_create_notification(instance.product, course_wishes, models.Notification.NOTIF_ACTION_CREATE) + + +def post_add_m2m_product_target_course_notification(action, instance, pk_set, **kwargs): + """ + Create a notification instance to each user who has a course_wish to participate to + a target_course of the saved product. + """ + if action == "post_add": + course_wishes = models.CourseWish.objects.filter(course__id__in=pk_set) + base_create_notification(instance, course_wishes, models.Notification.NOTIF_ACTION_ADD) diff --git a/src/backend/joanie/tests/core/test_models_notifications.py b/src/backend/joanie/tests/core/test_models_notifications.py new file mode 100644 index 000000000..399f75160 --- /dev/null +++ b/src/backend/joanie/tests/core/test_models_notifications.py @@ -0,0 +1,62 @@ +""" +Test suite for notification models +""" +import datetime +from datetime import timedelta +from unittest import mock + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.test.utils import override_settings +from django.utils import timezone + +from joanie.core import factories +from joanie.core.exceptions import GradeError +from joanie.lms_handler.backends.openedx import OpenEdXLMSBackend + + + +class NotificationModelsTestCase(TestCase): + """Test suite for the Notification model.""" + + def test_models_notification_str_not_sent(self): + user = factories.UserFactory(username="AmeliaP") + course = factories.CourseFactory(title="time-travel class") + course_wish = factories.CourseWishFactory.create(owner=user, course=course) + + course_run = factories.CourseRunFactory(course=course, is_listed=True, title="session 42") + + notification = factories.NotificationFactory( + owner=user, + notif_subject=course_wish, + notif_object=course_run + ) + print(notification) + self.assertEqual( + str(notification), + "'AmeliaP' hasn't been notified about 'session 42 [starting on]' according to " + "'AmeliaP's wish to participate at the course time-travel class' yet", + ) + + def test_models_notification_str_sent(self): + user = factories.UserFactory(username="AmeliaP") + course = factories.CourseFactory(title="time-travel class") + course_wish = factories.CourseWishFactory.create(owner=user, course=course) + + course_run = factories.CourseRunFactory(course=course, is_listed=True, title="session 42") + + notified_at = datetime.datetime(2010, 1, 1) + + notification = factories.NotificationFactory( + owner=user, + notif_subject=course_wish, + notif_object=course_run, + notified_at=notified_at + ) + print(notification) + self.assertEqual( + str(notification), + "'AmeliaP' has been notified about 'session 42 [starting on]' according to " + "'AmeliaP's wish to participate at the course time-travel class' " + "at '2010-01-01 00:00:00'", + ) diff --git a/src/backend/joanie/tests/core/test_signals_notifications.py b/src/backend/joanie/tests/core/test_signals_notifications.py new file mode 100644 index 000000000..e428a8714 --- /dev/null +++ b/src/backend/joanie/tests/core/test_signals_notifications.py @@ -0,0 +1,45 @@ +"""Joanie core helpers tests suite""" +import random +from datetime import datetime +from unittest import mock +from zoneinfo import ZoneInfo + +from django.test.testcases import TestCase + +from joanie.core import enums, factories, models +from joanie.core.utils import webhooks + +# pylint: disable=too-many-locals,too-many-public-methods,too-many-lines + + +class NotificationsTestCase(TestCase): + """Joanie core notifications tests case""" + + def test_course_wish_and_new_course_run_create_notification(self): + user = factories.UserFactory() + course = factories.CourseFactory() + factories.CourseWishFactory.create(owner=user, course=course) + + factories.CourseRunFactory(course=course, is_listed=True) + + self.assertEqual(models.Notification.objects.count(), 1) + + def test_course_wish_and_new_target_course_in_product_create_notification(self): + user = factories.UserFactory() + course = factories.CourseFactory() + factories.CourseWishFactory.create(owner=user, course=course) + + product = factories.ProductFactory() + product.target_courses.add(course) + product.save() + + self.assertEqual(models.Notification.objects.count(), 1) + + def test_course_wish_and_new_product_create_notification(self): + user = factories.UserFactory() + course = factories.CourseFactory() + factories.CourseWishFactory.create(owner=user, course=course) + + factories.ProductFactory(target_courses=(course, )) + + self.assertEqual(models.Notification.objects.count(), 1)