From 1aa21779dace86aa81902739b24ee2a56055dd8b Mon Sep 17 00:00:00 2001 From: Morgane Alonso Date: Thu, 23 Feb 2023 08:42:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(models)=20issue-196:=20add=20models?= =?UTF-8?q?=20and=20endpoints=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 the issue 196 (feature wishlist) This commit add: * model CourseWish that link a User and a Course * endpoints to use this new model, according to the auth User * GET wish list (can be filtered by course_code) * GET a wish * POST a wish * DELETE a wish --- CHANGELOG.md | 1 + src/backend/joanie/core/admin.py | 11 + src/backend/joanie/core/api.py | 46 +++ src/backend/joanie/core/factories.py | 10 + .../joanie/core/migrations/0002_coursewish.py | 72 ++++ src/backend/joanie/core/models/__init__.py | 1 + src/backend/joanie/core/models/wishlist.py | 37 ++ src/backend/joanie/core/serializers.py | 17 + .../joanie/tests/core/test_api_wishlist.py | 346 ++++++++++++++++++ src/backend/joanie/urls.py | 1 + 10 files changed, 542 insertions(+) create mode 100644 src/backend/joanie/core/migrations/0002_coursewish.py create mode 100644 src/backend/joanie/core/models/wishlist.py create mode 100644 src/backend/joanie/tests/core/test_api_wishlist.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 43da76b9e2..815a58c896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to - Add synchronization for course runs - Add make dbshell cmd to access database in cli +- Add model and endpoints for wishlist feature ### Fixed diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 85023f3596..84960743af 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -394,3 +394,14 @@ 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 eb45783b7a..8aec5ea573 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 addresses""" + user = User.update_or_create_from_request_user(request_user=self.request.user) + queryset = user.wishlists.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 address 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/factories.py b/src/backend/joanie/core/factories.py index 18892ea749..304078c3c1 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -390,3 +390,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 an user wish""" + + class Meta: + model = models.CourseWish + + course = factory.SubFactory(CourseFactory) + owner = factory.SubFactory(UserFactory) 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 0000000000..9b159e8b8d --- /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/models/__init__.py b/src/backend/joanie/core/models/__init__.py index 7b31af11e6..fba38bbfff 100644 --- a/src/backend/joanie/core/models/__init__.py +++ b/src/backend/joanie/core/models/__init__.py @@ -6,3 +6,4 @@ from .certifications import * from .courses import * from .products import * +from .wishlist import * diff --git a/src/backend/joanie/core/models/wishlist.py b/src/backend/joanie/core/models/wishlist.py new file mode 100644 index 0000000000..9c5de4e8dc --- /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 at a course + """ + + owner = models.ForeignKey( + to=User, + verbose_name=_("Owner"), + related_name="wishlists", + 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 63697dc362..1adc9f6543 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/tests/core/test_api_wishlist.py b/src/backend/joanie/tests/core/test_api_wishlist.py new file mode 100644 index 0000000000..214d675126 --- /dev/null +++ b/src/backend/joanie/tests/core/test_api_wishlist.py @@ -0,0 +1,346 @@ +""" +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 an 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) + response = self.client.get( + "/api/v1.0/wishlist/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + self.assertEqual(response.status_code, 200) + wish_data = response.data + self.assertEqual(len(wish_data), 0) + self.assertEqual(models.User.objects.get(username=username).username, username) + + def test_api_wish_get_wish(self): + """Get wish for a user in db with two wish 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) + + wish_data = response.data + self.assertEqual(len(wish_data), 2) + self.assertEqual(wish_data[0]["course"], course1.code) + self.assertEqual(wish_data[0]["id"], str(wish1.id)) + self.assertEqual(wish_data[1]["course"], course2.code) + self.assertEqual(wish_data[1]["id"], str(wish2.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/urls.py b/src/backend/joanie/urls.py index 3b4b1594bc..0b0837b383 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"