diff --git a/CHANGELOG.md b/CHANGELOG.md index 43da76b9e..96444bb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - Allow on-demand page size on the order and enrollment endpoints - Add yarn cli to generate joanie api client in TypeScript +- Add model and endpoints for wishlist feature ### Removed diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 85023f359..f1569e996 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -394,3 +394,16 @@ class AddressAdmin(admin.ModelAdmin): "is_main", "owner", ) + + +@admin.register(models.CourseWish) +class CourseWishAdmin(admin.ModelAdmin): + """Admin class for the CourseWish model""" + + list_display = ( + "course", + "owner", + ) + readonly_fields = ( + "id", + ) diff --git a/src/backend/joanie/core/api.py b/src/backend/joanie/core/api.py index eb45783b7..24a0d3814 100644 --- a/src/backend/joanie/core/api.py +++ b/src/backend/joanie/core/api.py @@ -417,3 +417,49 @@ def download(self, request, pk=None): # pylint: disable=no-self-use, invalid-na response["Content-Disposition"] = f"attachment; filename={pk}.pdf;" return response + + +class CourseWishViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """ + API view allows to get all wishlists or create a new one for a user. + + GET /api/wishlist/ + Return list of all wishes for a user + + GET /api/wishlist/?course_code= + Return list of wishes for a user filter by the course_code + + GET /api/wishlist// + Return selected wish + + POST /api/wishlist/ with expected data: + - course: str course_code + Return new wish just created + + DELETE /api/wishlist// + Delete selected wish + """ + + lookup_field = "id" + serializer_class = serializers.CourseWishSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + """Custom queryset to get user wishlist""" + user = User.update_or_create_from_request_user(request_user=self.request.user) + queryset = user.wishlist.all() + course_code = self.request.query_params.get("course_code") + if course_code is not None: + queryset = queryset.filter(course__code=course_code) + return queryset + + def perform_create(self, serializer): + """Create a new wish for user authenticated""" + user = User.update_or_create_from_request_user(request_user=self.request.user) + serializer.save(owner=user) 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 18892ea74..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 @@ -390,3 +391,29 @@ def certificate_definition(self): Return the order product certificate definition. """ return self.order.product.certificate_definition + + +class CourseWishFactory(factory.django.DjangoModelFactory): + """A factory to create an user wish""" + + class Meta: + model = models.CourseWish + + 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/0002_coursewish.py b/src/backend/joanie/core/migrations/0002_coursewish.py new file mode 100644 index 000000000..9b159e8b8 --- /dev/null +++ b/src/backend/joanie/core/migrations/0002_coursewish.py @@ -0,0 +1,72 @@ +# Generated by Django 4.0.10 on 2023-02-23 06:39 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="CourseWish", + 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", + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="wished_in_wishlists", + to="core.course", + verbose_name="Course", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="wishlists", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), + ), + ], + options={ + "verbose_name": "Course Wish", + "verbose_name_plural": "Course Wishes", + "db_table": "joanie_course_wish", + "unique_together": {("owner", "course")}, + }, + ), + ] 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 7b31af11e..6df6b2235 100644 --- a/src/backend/joanie/core/models/__init__.py +++ b/src/backend/joanie/core/models/__init__.py @@ -6,3 +6,5 @@ from .certifications import * 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 new file mode 100644 index 000000000..6bc332ca0 --- /dev/null +++ b/src/backend/joanie/core/models/wishlist.py @@ -0,0 +1,37 @@ +""" +Declare and configure the models for the wishlist part +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from . import Course, User +from .base import BaseModel + + +class CourseWish(BaseModel): + """ + CourseWish represents and records a user wish to participate to a course + """ + + owner = models.ForeignKey( + to=User, + verbose_name=_("Owner"), + related_name="wishlist", + on_delete=models.PROTECT, + ) + + course = models.ForeignKey( + to=Course, + verbose_name=_("Course"), + related_name="wished_in_wishlists", + on_delete=models.PROTECT, + ) + + class Meta: + db_table = "joanie_course_wish" + verbose_name = _("Course Wish") + verbose_name_plural = _("Course Wishes") + unique_together = ("owner", "course") + + def __str__(self): + return f"{self.owner}'s wish to participate at the course {self.course}" diff --git a/src/backend/joanie/core/serializers.py b/src/backend/joanie/core/serializers.py index 63697dc36..1adc9f654 100644 --- a/src/backend/joanie/core/serializers.py +++ b/src/backend/joanie/core/serializers.py @@ -9,6 +9,7 @@ from joanie.core import models, utils from .enums import ORDER_STATE_PENDING, ORDER_STATE_VALIDATED +from .models import Course class CertificationDefinitionSerializer(serializers.ModelSerializer): @@ -545,3 +546,19 @@ class Meta: model = models.Certificate fields = ["id"] read_only_fields = ["id"] + + +class CourseWishSerializer(serializers.ModelSerializer): + """ + CourseWish model serializer + """ + + id = serializers.CharField(read_only=True, required=False) + course = serializers.SlugRelatedField( + slug_field="code", queryset=Course.objects.all() + ) + + class Meta: + model = models.CourseWish + fields = ["id", "course"] + read_only_fields = ["id"] 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_api_wishlist.py b/src/backend/joanie/tests/core/test_api_wishlist.py new file mode 100644 index 000000000..ff0ef9d48 --- /dev/null +++ b/src/backend/joanie/tests/core/test_api_wishlist.py @@ -0,0 +1,381 @@ +""" +Test suite for wish API +""" +import json + +import arrow + +from joanie.core import factories, models +from joanie.tests.base import BaseAPITestCase + + +def get_payload(wish): + """ + According to a CourseWish object, return a valid payload required by + create wish api routes. + """ + return { + "course": wish.course.code, + } + + +# pylint: disable=too-many-public-methods +class CourseWishAPITestCase(BaseAPITestCase): + """Manage user wish API test case""" + + def test_api_wish_get_wish_without_authorization(self): + """Get user wish not allowed without HTTP AUTH""" + # Try to get wish without Authorization + response = self.client.get("/api/v1.0/wishlist/") + self.assertEqual(response.status_code, 401) + content = json.loads(response.content) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_wish_get_wish_with_bad_token(self): + """Get user wish not allowed with bad user token""" + # Try to get wish with bad token + response = self.client.get( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION="Bearer nawak", + ) + self.assertEqual(response.status_code, 401) + content = json.loads(response.content) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_get_wish_with_expired_token(self): + """Get user wish not allowed with user token expired""" + # Try to get wish with expired token + token = self.get_user_token( + "panoramix", + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + response = self.client.get( + "/api/v1.0/wishlist/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 401) + content = json.loads(response.content) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_get_wish_for_new_user(self): + """If we try to get wish for a user not in db, we create a new user first""" + username = "panoramix" + token = self.get_user_token(username) + + self.assertFalse(models.User.objects.filter(username=username).exists()) + + response = self.client.get( + "/api/v1.0/wishlist/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + self.assertEqual(response.status_code, 200) + results = response.data["results"] + self.assertEqual(len(results), 0) + self.assertTrue(models.User.objects.filter(username=username).exists()) + + def test_api_wish_get_wishes(self): + """Get wish for a user in db with two wishes linked to him""" + user = factories.UserFactory() + course1 = factories.CourseFactory() + course2 = factories.CourseFactory() + token = self.get_user_token(user.username) + wish1 = factories.CourseWishFactory.create(owner=user, course=course1) + wish2 = factories.CourseWishFactory.create(owner=user, course=course2) + response = self.client.get( + "/api/v1.0/wishlist/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + self.assertEqual(response.status_code, 200) + + results = response.data["results"] + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["course"], course1.code) + self.assertEqual(results[0]["id"], str(wish1.id)) + self.assertEqual(results[1]["course"], course2.code) + self.assertEqual(results[1]["id"], str(wish2.id)) + + def test_api_wish_get_wishes_filter_by_code(self): + """Get wish for a user in db with two wishes linked to him""" + user = factories.UserFactory() + course = factories.CourseFactory() + token = self.get_user_token(user.username) + wish = factories.CourseWishFactory.create(owner=user, course=course) + factories.CourseWishFactory.create(owner=user) + + get_url = f"/api/v1.0/wishlist/?course_code={course.code}" + response = self.client.get(get_url, HTTP_AUTHORIZATION=f"Bearer {token}") + self.assertEqual(response.status_code, 200) + + results = response.data["results"] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["course"], course.code) + self.assertEqual(results[0]["id"], str(wish.id)) + + def test_api_wish_get_wish(self): + """Get wish for a user in db with two wishes linked to him""" + user = factories.UserFactory() + token = self.get_user_token(user.username) + wish = factories.CourseWishFactory.create(owner=user) + factories.CourseWishFactory.create(owner=user) + + get_url = f"/api/v1.0/wishlist/{wish.id}/" + response = self.client.get(get_url, HTTP_AUTHORIZATION=f"Bearer {token}") + self.assertEqual(response.status_code, 200) + + data = response.data + self.assertEqual(data["course"], wish.course.code) + self.assertEqual(data["id"], str(wish.id)) + + def test_api_wish_create_without_authorization(self): + """Create/update user wish not allowed without HTTP AUTH""" + # Try to create wish without Authorization + wish = factories.CourseWishFactory.build() + + response = self.client.post( + "/api/v1.0/wishlist/", + data=get_payload(wish), + content_type="application/json", + ) + self.assertEqual(response.status_code, 401) + content = json.loads(response.content) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_wish_update_without_authorization(self): + """Update user wish not allowed without HTTP AUTH""" + # Try to update wish without Authorization + user = factories.UserFactory() + wish = factories.CourseWishFactory(owner=user) + new_wish = factories.CourseWishFactory.build() + + response = self.client.put( + f"/api/v1.0/wishlist/{wish.id}", + data=get_payload(new_wish), + follow=True, + content_type="application/json", + ) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 401) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_wish_create_with_bad_token(self): + """Create wish not allowed with bad user token""" + # Try to create wish with bad token + wish = factories.CourseWishFactory.build() + + response = self.client.post( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION="Bearer nawak", + data=get_payload(wish), + content_type="application/json", + ) + self.assertEqual(response.status_code, 401) + content = json.loads(response.content) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_update_with_bad_token(self): + """Update wish not allowed with bad user token""" + # Try to update wish with bad token + user = factories.UserFactory() + wish = factories.CourseWishFactory.create(owner=user) + new_wish = factories.CourseWishFactory.build() + + response = self.client.put( + f"/api/v1.0/wishlist/{wish.id}", + HTTP_AUTHORIZATION="Bearer nawak", + data=get_payload(new_wish), + follow=True, + content_type="application/json", + ) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 401) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_create_with_expired_token(self): + """Create user wish not allowed with user token expired""" + # Try to create wish with expired token + user = factories.UserFactory() + token = self.get_user_token( + user.username, + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + wish = factories.CourseWishFactory.build() + + response = self.client.post( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=get_payload(wish), + content_type="application/json", + ) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 401) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_update_with_expired_token(self): + """Update user wish not allowed with user token expired""" + # Try to update wish with expired token + user = factories.UserFactory() + token = self.get_user_token( + user.username, + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + wish = factories.CourseWishFactory.create(owner=user) + new_wish = factories.CourseWishFactory.build() + + response = self.client.put( + f"/api/v1.0/wishlist/{wish.id}", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=get_payload(new_wish), + follow=True, + content_type="application/json", + ) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 401) + self.assertEqual(content["code"], "token_not_valid") + + def test_api_wish_create_with_bad_payload(self): + """Create user wish with valid token but bad data""" + username = "panoramix" + token = self.get_user_token(username) + wish = factories.CourseWishFactory.build() + bad_payload = get_payload(wish).copy() + bad_payload["course"] = "my very wrong course code" + + response = self.client.post( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=bad_payload, + ) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + content, + {"course": ["Object with code=my very wrong course code does not exist."]}, + ) + self.assertFalse(models.User.objects.exists()) + + del bad_payload["course"] + response = self.client.post( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=bad_payload, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + content = json.loads(response.content) + self.assertEqual(content, {"course": ["This field is required."]}) + + def test_api_wish_update(self): + """ + User should not be able to update a wish + """ + user = factories.UserFactory() + token = self.get_user_token(user.username) + wish = factories.CourseWishFactory(owner=user) + + payload = get_payload(wish) + + response = self.client.put( + f"/api/v1.0/wishlist/{wish.id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + content_type="application/json", + data=payload, + ) + + self.assertEqual(response.status_code, 405) + self.assertEqual( + json.loads(response.content), + {"detail": 'Method "PUT" not allowed.'}, + ) + + def test_api_wish_create(self): + """Create user wish with valid token and data""" + username = "panoramix" + token = self.get_user_token(username) + course = factories.CourseFactory() + wish = factories.CourseWishFactory.build(course=course) + payload = get_payload(wish) + + response = self.client.post( + "/api/v1.0/wishlist/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=payload, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + + # panoramix was a unknown user, so a new user was created + owner = models.User.objects.get() + self.assertEqual(owner.username, username) + + # new wish was created for user panoramix + wish = models.CourseWish.objects.get() + self.assertEqual(wish.owner, owner) + self.assertEqual(wish.course.code, payload["course"]) + + def test_api_wish_delete_without_authorization(self): + """Delete wish is not allowed without authorization""" + user = factories.UserFactory() + wish = factories.CourseWishFactory.create(owner=user) + response = self.client.delete( + f"/api/v1.0/wishlist/{wish.id}/", + ) + self.assertEqual(response.status_code, 401) + + def test_api_wish_delete_with_bad_authorization(self): + """Delete wish is not allowed with bad authorization""" + user = factories.UserFactory() + wish = factories.CourseWishFactory.create(owner=user) + response = self.client.delete( + f"/api/v1.0/wishlist/{wish.id}/", + HTTP_AUTHORIZATION="Bearer nawak", + ) + self.assertEqual(response.status_code, 401) + + def test_api_wish_delete_with_expired_token(self): + """Delete wish is not allowed with expired token""" + user = factories.UserFactory() + token = self.get_user_token( + user.username, + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + wish = factories.CourseWishFactory.create(owner=user) + response = self.client.delete( + f"/api/v1.0/wishlist/{wish.id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 401) + + def test_api_wish_delete_with_bad_user(self): + """User token has to match with owner of wish to delete""" + # create an wish for a user + wish = factories.CourseWishFactory() + # now use a token for an other user to update wish + token = self.get_user_token("panoramix") + response = self.client.delete( + f"/api/v1.0/wishlist/{wish.id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 404) + + def test_api_wish_delete(self): + """Delete wish is allowed with valid token""" + user = factories.UserFactory() + token = self.get_user_token(user.username) + wish = factories.CourseWishFactory.create(owner=user) + response = self.client.delete( + f"/api/v1.0/wishlist/{wish.id}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 204) + self.assertFalse(models.CourseWish.objects.exists()) 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) diff --git a/src/backend/joanie/urls.py b/src/backend/joanie/urls.py index 3b4b1594b..0b0837b38 100644 --- a/src/backend/joanie/urls.py +++ b/src/backend/joanie/urls.py @@ -38,6 +38,7 @@ router.register("orders", api.OrderViewSet, basename="orders") router.register("course-runs", api.CourseRunViewSet, basename="course-runs") router.register("products", api.ProductViewSet, basename="products") +router.register("wishlist", api.CourseWishViewSet, basename="wishlists") API_VERSION = "v1.0"