From 3f1f202fd10d1c78c43f6111cf42ae7d8bbc04af Mon Sep 17 00:00:00 2001 From: Payam Date: Thu, 4 Dec 2025 22:07:11 +0400 Subject: [PATCH] feat: #20 Add GET method for course content API to list quiz and lesson summary --- CONTRIBUTING.md | 2 +- django_email_learning/api/serializers.py | 14 ++++ django_email_learning/api/views.py | 16 ++++ django_email_learning/models.py | 8 ++ pyproject.toml | 2 +- .../test_views/test_course_content_view.py | 81 +++++++++++++++++++ 6 files changed, 121 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b5f28c6..9453cd69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,6 +97,6 @@ make pre-commit - **Type Safety**: All Python code must pass MyPy type checking - **Code Style**: Use Ruff for formatting and linting -- **Test Coverage**: Maintain minimum 75% test coverage +- **Test Coverage**: Maintain minimum 80% test coverage - **Security**: Code is scanned with Bandit for security issues - **Commit Messages**: Follow [Conventional Commits](https://www.conventionalcommits.org/) specification diff --git a/django_email_learning/api/serializers.py b/django_email_learning/api/serializers.py index 483dd735..751dfb39 100644 --- a/django_email_learning/api/serializers.py +++ b/django_email_learning/api/serializers.py @@ -342,3 +342,17 @@ def serialize_waiting_period(self, waiting_period: int) -> dict: return WaitingPeriod.from_seconds(waiting_period).model_dump() model_config = ConfigDict(from_attributes=True) + + +class CourseContentSummaryResponse(BaseModel): + id: int + title: str + priority: int + waiting_period: int + type: str + + @field_serializer("waiting_period") + def serialize_waiting_period(self, waiting_period: int) -> dict: + return WaitingPeriod.from_seconds(waiting_period).model_dump() + + model_config = ConfigDict(from_attributes=True) diff --git a/django_email_learning/api/views.py b/django_email_learning/api/views.py index a41369d4..ff1f57a1 100644 --- a/django_email_learning/api/views.py +++ b/django_email_learning/api/views.py @@ -60,6 +60,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") +@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get") class CourseContentView(View): def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] payload = json.loads(request.body) @@ -81,6 +82,21 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt except DjangoValidationError as e: return JsonResponse({"error": e.messages}, status=400) + def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + try: + course = Course.objects.get(id=kwargs["course_id"]) + course_contents = course.coursecontent_set.all().order_by("priority") + response_list = [] + for content in course_contents: + response_list.append( + serializers.CourseContentSummaryResponse.model_validate( + content + ).model_dump() + ) + return JsonResponse({"course_contents": response_list}, status=200) + except Course.DoesNotExist: + return JsonResponse({"error": "Course not found"}, status=404) + @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") @method_decorator(accessible_for(roles={"admin", "editor"}), name="delete") diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 23d85991..f1fdaed1 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -227,6 +227,14 @@ def __str__(self) -> str: return f"{self.priority} - Quiz: {self.quiz.title}" return f"{self.course.title} content #{self.priority}" + @property + def title(self) -> str: + if self.type == "lesson" and self.lesson: + return self.lesson.title + elif self.type == "quiz" and self.quiz: + return self.quiz.title + return "Untitled Content" + def _validate_content(self) -> None: if self.type == "lesson" and not self.lesson: raise ValidationError("Lesson must be provided for lesson content.") diff --git a/pyproject.toml b/pyproject.toml index 1604b6dc..9b8e26f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ addopts = [ "--cov-report=term-missing", # Show missing lines in terminal "--cov-report=html:htmlcov", # Generate HTML report "--cov-report=xml", # Generate XML report (for CI) - "--cov-fail-under=75", # Fail if coverage below 80% + "--cov-fail-under=80", # Fail if coverage below 80% ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", diff --git a/tests/api/test_views/test_course_content_view.py b/tests/api/test_views/test_course_content_view.py index 9b828c25..5325133c 100644 --- a/tests/api/test_views/test_course_content_view.py +++ b/tests/api/test_views/test_course_content_view.py @@ -263,3 +263,84 @@ def test_invalid_quiz_content_no_correct_answer(superadmin_client, create_course ) assert response.status_code == 400 assert "error" in response.json() + + +@pytest.mark.parametrize( + "client", ["superadmin", "editor", "viewer"], indirect=["client"] +) +def test_list_course_content_access(client, create_course): + url = get_url() + response = client.get(url) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["course_contents"], list) + assert len(data["course_contents"]) == 0 + + +def test_anonymous_user_cannot_list_course_content(anonymous_client, create_course): + url = get_url() + response = anonymous_client.get(url) + assert response.status_code == 401 + + +def test_list_course_content_with_existing_contents(superadmin_client, create_course): + url = get_url() + # Create a lesson content + lesson_payload = { + "content": { + "title": LESSON_TITLE, + "content": LESSON_CONTENT, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + superadmin_client.post( + url, json.dumps(lesson_payload), content_type="application/json" + ) + + # Create a quiz content + quiz_payload = { + "content": { + "type": "quiz", + "title": "Quiz 1", + "required_score": 70, + "questions": [ + { + "text": "What is Python?", + "priority": 1, + "answers": [ + {"text": "A programming language", "is_correct": True}, + {"text": "A snake", "is_correct": False}, + ], + } + ], + }, + "priority": 2, + "waiting_period": {"period": 1, "type": "hours"}, + } + superadmin_client.post( + url, json.dumps(quiz_payload), content_type="application/json" + ) + + # Now list the course contents + response = superadmin_client.get(url) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["course_contents"], list) + assert len(data["course_contents"]) == 2 + assert data["course_contents"][0]["type"] == "lesson" + assert data["course_contents"][1]["type"] == "quiz" + assert data["course_contents"][0]["priority"] == 1 + assert data["course_contents"][1]["priority"] == 2 + assert data["course_contents"][0]["id"] is not None + assert data["course_contents"][1]["id"] is not None + assert data["course_contents"][0]["title"] == LESSON_TITLE + assert data["course_contents"][1]["title"] == "Quiz 1" + assert data["course_contents"][0]["waiting_period"] == {"period": 2, "type": "days"} + assert data["course_contents"][1]["waiting_period"] == { + "period": 1, + "type": "hours", + } + assert "lesson" not in data["course_contents"][0] + assert "quiz" not in data["course_contents"][1]