From 71a58e4537683014ac45d33557e5036af2543ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20Paccoud=20-=20France=20Universit=C3=A9=20Num?= =?UTF-8?q?=C3=A9rique?= Date: Wed, 5 Jul 2023 00:19:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(models/api)=20add=20models=20and=20en?= =?UTF-8?q?dpoints=20to=20user=20wishlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit is the first part of resolving issue 196 (course wishes). We add a CourseWish model and an API endpoint as action on the existing course endpoint. co-authored with Morgane Alonso --- CHANGELOG.md | 1 + src/backend/joanie/core/admin.py | 19 ++ src/backend/joanie/core/api/client.py | 39 ++- src/backend/joanie/core/factories.py | 10 + .../core/migrations/0006_add_coursewish.py | 257 +++++++++++++++++ src/backend/joanie/core/models/__init__.py | 1 + .../joanie/core/models/course_wishes.py | 36 +++ .../tests/core/test_api_course_wishes.py | 265 ++++++++++++++++++ 8 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 src/backend/joanie/core/migrations/0006_add_coursewish.py create mode 100644 src/backend/joanie/core/models/course_wishes.py create mode 100644 src/backend/joanie/tests/core/test_api_course_wishes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a35caf79..ddafbba46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to - Rename certificate field into certificate_definition for the ProductSerializer - Improve certificate serializer - Upgrade to Django 4.2 +- Add model and API endpoint for course wishes ### Removed diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 081650e39..2f8edc29b 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -548,3 +548,22 @@ def changelist_view(self, request, extra_context=None): extra_context = extra_context or {} extra_context["subtitle"] = _("To get results, choose an owner on the right") return super().changelist_view(request, extra_context=extra_context) + + +@admin.register(models.CourseWish) +class CourseWishAdmin(admin.ModelAdmin): + """Admin class for the CourseWish model""" + + list_display = ( + "course", + "owner", + ) + list_filter = [CourseFilter, OwnerFilter] + readonly_fields = ("id",) + search_fields = [ + "owner__last_name", + "owner__username", + "owner__email", + "course__code", + "course__title", + ] diff --git a/src/backend/joanie/core/api/client.py b/src/backend/joanie/core/api/client.py index a295c92dd..bb082fd68 100644 --- a/src/backend/joanie/core/api/client.py +++ b/src/backend/joanie/core/api/client.py @@ -629,24 +629,24 @@ class CourseAccessViewSet( """ API ViewSet for all interactions with course accesses. - GET /api/course//accesses/: + GET /api/courses//accesses/: Return list of all course accesses related to the logged-in user or one course access if an id is provided. - POST /api//accesses/ with expected data: + POST /api/courses//accesses/ with expected data: - user: str - role: str [owner|admin|member] Return newly created course access - PUT /api//accesses// with expected data: + PUT /api/courses//accesses// with expected data: - role: str [owner|admin|member] Return updated course access - PATCH /api//accesses// with expected data: + PATCH /api/courses//accesses// with expected data: - role: str [owner|admin|member] Return partially updated course access - DELETE /api//accesses// + DELETE /api/courses//accesses// Delete targeted course access """ @@ -704,9 +704,19 @@ class CourseViewSet( GET /api/courses/: Return one course if an id is provided. + + GET /api/courses/:/wish + Return wish status on this course for the authenticated user + + POST /api/courses/:/wish + Confirm a wish on this course for the authenticated user + + DELETE /api/courses/:/wish + Delete any existing wish on this course for the authenticated user """ lookup_field = "pk" + lookup_value_regex = "[0-9a-z-]*" filterset_class = filters.CourseViewSetFilter pagination_class = Pagination permission_classes = [permissions.AccessPermission] @@ -737,3 +747,22 @@ def get_queryset(self): return courses.annotate(user_role=Subquery(user_role_query)).prefetch_related( "organizations", "products", "course_runs" ) + + @action( + detail=True, + methods=["post", "get", "delete"], + permission_classes=[permissions.IsAuthenticated], + ) + # pylint: disable=invalid-name + def wish(self, request, pk=None): + """Action to handle the wish on this course for the logged-in user.""" + params = {"course": models.Course(pk=pk), "owner": request.user} + if request.method == "POST": + models.CourseWish.objects.get_or_create(**params) + is_wished = True + elif request.method == "DELETE": + models.CourseWish.objects.filter(**params).delete() + is_wished = False + else: + is_wished = models.CourseWish.objects.filter(**params).exists() + return Response({"status": is_wished}) diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index 7eddeabb7..89a2dc8a8 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -474,3 +474,13 @@ 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 a course wish for a user.""" + + class Meta: + model = models.CourseWish + + course = factory.SubFactory(CourseFactory) + owner = factory.SubFactory(UserFactory) diff --git a/src/backend/joanie/core/migrations/0006_add_coursewish.py b/src/backend/joanie/core/migrations/0006_add_coursewish.py new file mode 100644 index 000000000..b6f5fb4ec --- /dev/null +++ b/src/backend/joanie/core/migrations/0006_add_coursewish.py @@ -0,0 +1,257 @@ +# Generated by Django 4.2.2 on 2023-07-04 17:22 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import joanie.core.fields.multiselect + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0005_courseproductrelation_add_max_validated_orders"), + ] + + 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", + ), + ), + ], + options={ + "verbose_name": "Course Wish", + "verbose_name_plural": "Course Wishes", + "db_table": "joanie_course_wish", + }, + ), + migrations.AlterModelOptions( + name="certificatedefinition", + options={ + "ordering": ["-created_on"], + "verbose_name": "Certificate definition", + "verbose_name_plural": "Certificate definitions", + }, + ), + migrations.AlterModelOptions( + name="courseaccess", + options={ + "ordering": ["-created_on"], + "verbose_name": "Course access", + "verbose_name_plural": "Course accesses", + }, + ), + migrations.AlterModelOptions( + name="courseproductrelation", + options={ + "ordering": ["-created_on"], + "verbose_name": "Course relation to a product", + "verbose_name_plural": "Courses relations to products", + }, + ), + migrations.AlterModelOptions( + name="courserun", + options={ + "ordering": ["-created_on"], + "verbose_name": "Course run", + "verbose_name_plural": "Course runs", + }, + ), + migrations.AlterModelOptions( + name="organization", + options={ + "ordering": ["-created_on"], + "verbose_name": "Organization", + "verbose_name_plural": "Organizations", + }, + ), + migrations.AlterModelOptions( + name="organizationaccess", + options={ + "ordering": ["-created_on"], + "verbose_name": "Organization access", + "verbose_name_plural": "Organization accesses", + }, + ), + migrations.AlterModelOptions( + name="product", + options={ + "ordering": ["-created_on"], + "verbose_name": "Product", + "verbose_name_plural": "Products", + }, + ), + migrations.RemoveConstraint( + model_name="order", + name="unique_owner_product_not_canceled", + ), + migrations.AlterField( + model_name="courserun", + name="languages", + field=joanie.core.fields.multiselect.MultiSelectField( + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + help_text="The list of languages in which the course content is available.", + max_choices=50, + max_length=255, + ), + ), + migrations.AddConstraint( + model_name="order", + constraint=models.UniqueConstraint( + condition=models.Q(("state", "canceled"), _negated=True), + fields=("course", "owner", "product"), + name="unique_owner_product_not_canceled", + violation_error_message="An order for this product and course already exists.", + ), + ), + migrations.AddField( + model_name="coursewish", + name="course", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="wishes", + to="core.course", + verbose_name="Course", + ), + ), + migrations.AddField( + model_name="coursewish", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="course_wishes", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), + ), + migrations.AlterUniqueTogether( + name="coursewish", + unique_together={("owner", "course")}, + ), + ] diff --git a/src/backend/joanie/core/models/__init__.py b/src/backend/joanie/core/models/__init__.py index 7b31af11e..2a0dbf356 100644 --- a/src/backend/joanie/core/models/__init__.py +++ b/src/backend/joanie/core/models/__init__.py @@ -4,5 +4,6 @@ from .accounts import * from .certifications import * +from .course_wishes import * from .courses import * from .products import * diff --git a/src/backend/joanie/core/models/course_wishes.py b/src/backend/joanie/core/models/course_wishes.py new file mode 100644 index 000000000..b3e54adee --- /dev/null +++ b/src/backend/joanie/core/models/course_wishes.py @@ -0,0 +1,36 @@ +""" +Declare and configure models for course wishes +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .base import BaseModel + + +class CourseWish(BaseModel): + """ + CourseWish represents and records a user wish to participate in a course + """ + + owner = models.ForeignKey( + to="User", + verbose_name=_("Owner"), + related_name="course_wishes", + on_delete=models.PROTECT, + ) + + course = models.ForeignKey( + to="Course", + verbose_name=_("Course"), + related_name="wishes", + 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 in {self.course}" diff --git a/src/backend/joanie/tests/core/test_api_course_wishes.py b/src/backend/joanie/tests/core/test_api_course_wishes.py new file mode 100644 index 000000000..1eef32de3 --- /dev/null +++ b/src/backend/joanie/tests/core/test_api_course_wishes.py @@ -0,0 +1,265 @@ +""" +Test suite for wish API +""" +import arrow + +from joanie.core import factories, models +from joanie.tests.base import BaseAPITestCase + + +# pylint: disable=too-many-public-methods +class CourseWishAPITestCase(BaseAPITestCase): + """Manage user course wish API test case""" + + def test_api_course_wish_get_anonymous(self): + """An anonymous user should not be able to get a course wish.""" + course = factories.CourseWishFactory() + response = self.client.get(f"/api/v1.0/courses/{course.id}/wish/") + + self.assertEqual(response.status_code, 401) + self.assertEqual( + response.json(), {"detail": "Authentication credentials were not provided."} + ) + + def test_api_course_wish_get_bad_token(self): + """It should not be possible to get a course wish with a bad user token.""" + course = factories.CourseWishFactory() + response = self.client.get( + f"/api/v1.0/courses/{course.id}/wish/", + HTTP_AUTHORIZATION="Bearer nawak", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + + def test_api_course_wish_get_expired_token(self): + """Get user wish not allowed with user token expired""" + course = factories.CourseWishFactory() + token = self.get_user_token( + "panoramix", + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + response = self.client.get( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + + def test_api_course_wish_get_new_user(self): + """ + If we try to get a course wish for a user not in db, a new user is created first. + """ + course = factories.CourseWishFactory() + username = "panoramix" + token = self.get_user_token(username) + + self.assertFalse(models.User.objects.filter(username=username).exists()) + + response = self.client.get( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": False}) + self.assertTrue(models.User.objects.filter(username=username).exists()) + + def test_api_course_wish_get_existing(self): + """Get existing course wish for a user present in db.""" + user = factories.UserFactory() + token = self.get_user_token(user.username) + course = factories.CourseFactory() + factories.CourseWishFactory(owner=user, course=course) + + response = self.client.get( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": True}) + + def test_api_course_wish_get_absent(self): + """Get absent course wish for a user present in db.""" + user = factories.UserFactory() + token = self.get_user_token(user.username) + course = factories.CourseFactory() + + response = self.client.get( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": False}) + + def test_api_course_wish_create_anonymous(self): + """Anonymous users should not be allowed to create a course wish.""" + course = factories.CourseFactory() + + response = self.client.post( + f"/api/v1.0/courses/{course.id}/wish/", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual( + response.json(), {"detail": "Authentication credentials were not provided."} + ) + + def test_api_course_wish_create_with_bad_token(self): + """It should not be possible to create a course wish with a bad user token.""" + course = factories.CourseFactory() + + response = self.client.post( + f"/api/v1.0/courses/{course.id}/wish/", + HTTP_AUTHORIZATION="Bearer nawak", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + + def test_api_course_wish_create_with_expired_token(self): + """Create user wish not allowed with user token expired""" + course = factories.CourseFactory() + user = factories.UserFactory() + token = self.get_user_token( + user.username, + expires_at=arrow.utcnow().shift(days=-1).datetime, + ) + + response = self.client.post( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + + def test_api_course_wish_create_success(self): + """Logged-in users should be able to create a course wish.""" + course = factories.CourseFactory() + user = factories.UserFactory() + token = self.get_user_token(user.username) + + response = self.client.post( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": True}) + + def test_api_course_wish_create_existing(self): + """Trying to create a course wish that already exists should work as if it was created.""" + course = factories.CourseFactory() + user = factories.UserFactory() + token = self.get_user_token(user.username) + models.CourseWish.objects.create(course=course, owner=user) + + response = self.client.post( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": True}) + + def test_api_course_wish_update_success(self): + """Updating a wish is not supported.""" + course = factories.CourseFactory() + user = factories.UserFactory() + token = self.get_user_token(user.username) + models.CourseWish.objects.create(course=course, owner=user) + + response = self.client.put( + f"/api/v1.0/courses/{course.id}/wish/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json(), {"detail": 'Method "PUT" not allowed.'}) + + def test_api_course_wish_delete_anonymous(self): + """Anonymous users should not be allowed to delete a wish.""" + user = factories.UserFactory() + wish = factories.CourseWishFactory.create(owner=user) + + response = self.client.delete( + f"/api/v1.0/courses/{wish.course.id}/wish/", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual( + response.json(), {"detail": "Authentication credentials were not provided."} + ) + self.assertTrue(models.CourseWish.objects.exists()) + + def test_api_course_wish_delete_bad_token(self): + """It should not be possible to delete a course wish with a bad user token.""" + user = factories.UserFactory() + wish = factories.CourseWishFactory.create(owner=user) + + response = self.client.delete( + f"/api/v1.0/courses/{wish.course.id}/wish/", + HTTP_AUTHORIZATION="Bearer nawak", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + self.assertTrue(models.CourseWish.objects.exists()) + + def test_api_course_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/courses/{wish.course.id}/wish/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["code"], "token_not_valid") + self.assertTrue(models.CourseWish.objects.exists()) + + def test_api_course_wish_delete_success(self): + """Delete course 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/courses/{wish.course.id}/wish/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(models.CourseWish.objects.exists()) + + def test_api_course_wish_delete_absent(self): + """Trying to delete course wish that does not exist should work as if it did.""" + course = factories.CourseFactory() + user = factories.UserFactory() + token = self.get_user_token(user.username) + + response = self.client.delete( + f"/api/v1.0/courses/{course.id}/wish/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(models.CourseWish.objects.exists())