Skip to content

Commit 09e1d36

Browse files
taimoor-ahmed-1Taimoor  Ahmedclaude
authored andcommitted
[FC-0018] feat: Standardize HomePageCoursesView (#38366)
Standardize HomePageCoursesView Co-authored-by: Taimoor Ahmed <taimoor.ahmed@A006-01711.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 07703ef commit 09e1d36

5 files changed

Lines changed: 341 additions & 7 deletions

File tree

cms/djangoapps/contentstore/rest_api/v1/urls.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.conf import settings
44
from django.urls import path, re_path
5+
from rest_framework.routers import DefaultRouter
56

67
from openedx.core.constants import COURSE_ID_PATTERN
78

@@ -23,6 +24,7 @@
2324
HomePageCoursesView,
2425
HomePageLibrariesView,
2526
HomePageView,
27+
HomeViewSet,
2628
ProctoredExamSettingsView,
2729
ProctoringErrorsView,
2830
VideoDownloadView,
@@ -34,7 +36,13 @@
3436

3537
VIDEO_ID_PATTERN = r'(?P<edx_video_id>[-\w]+)'
3638

37-
urlpatterns = [
39+
# ADR 0028: ViewSets registered via DefaultRouter.
40+
router = DefaultRouter()
41+
router.register(r'home', HomeViewSet, basename='home')
42+
43+
urlpatterns = router.urls + [
44+
# DEPRECATED (ADR 0028): Use HomeViewSet instead (GET home/, home/courses/, home/libraries/).
45+
# Kept as backward-compatible aliases. Remove after one named release.
3846
path(
3947
'home',
4048
HomePageView.as_view(),

cms/djangoapps/contentstore/rest_api/v1/views/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Views for v1 contentstore API.
33
"""
4+
45
from .certificates import CourseCertificatesView # noqa: F401
56
from .course_details import CourseDetailsView # noqa: F401
67
from .course_index import ContainerChildrenView, CourseIndexView # noqa: F401
@@ -10,7 +11,7 @@
1011
from .grading import CourseGradingView # noqa: F401
1112
from .group_configurations import CourseGroupConfigurationsView # noqa: F401
1213
from .help_urls import HelpUrlsView # noqa: F401
13-
from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView # noqa: F401
14+
from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView, HomeViewSet # noqa: F401
1415
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView # noqa: F401
1516
from .settings import CourseSettingsView # noqa: F401
1617
from .textbooks import CourseTextbooksView # noqa: F401

cms/djangoapps/contentstore/rest_api/v1/views/home.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import edx_api_doc_tools as apidocs
44
from django.conf import settings
5+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
6+
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
57
from organizations import api as org_api
8+
from rest_framework import viewsets
9+
from rest_framework.decorators import action
10+
from rest_framework.permissions import IsAuthenticated
611
from rest_framework.request import Request
712
from rest_framework.response import Response
813
from rest_framework.views import APIView
@@ -13,6 +18,145 @@
1318
from ..serializers import CourseHomeTabSerializer, LibraryTabSerializer, StudioHomeSerializer
1419

1520

21+
# ADR 0028 – consolidated from HomePageView, HomePageCoursesView, HomePageLibrariesView
22+
class HomeViewSet(viewsets.ViewSet):
23+
"""
24+
ViewSet for the Studio home page. Registered via DefaultRouter (basename ``home``).
25+
26+
Router-generated URLs:
27+
GET /api/contentstore/v1/home/ → list (aggregated home context)
28+
GET /api/contentstore/v1/home/courses/ → courses (course list only)
29+
GET /api/contentstore/v1/home/libraries/→ libraries (library list only)
30+
31+
ADR 0025 compliance notes:
32+
- Three different serializers are returned by ``get_serializer_class()`` depending
33+
on the action. ``serializer_class`` is set to the default (``StudioHomeSerializer``).
34+
- All response formatting is handled by the respective serializer class.
35+
"""
36+
37+
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
38+
permission_classes = (IsAuthenticated,) # ADR 0026
39+
serializer_class = StudioHomeSerializer # default; overridden by get_serializer_class()
40+
41+
def get_serializer_class(self):
42+
"""Return the appropriate serializer class for the current action."""
43+
if self.action == 'courses':
44+
return CourseHomeTabSerializer
45+
if self.action == 'libraries':
46+
return LibraryTabSerializer
47+
return StudioHomeSerializer
48+
49+
def get_serializer(self, *args, **kwargs):
50+
"""Return a serializer instance using the action-appropriate class."""
51+
return self.get_serializer_class()(*args, **kwargs)
52+
53+
@apidocs.schema(
54+
parameters=[
55+
apidocs.string_parameter(
56+
"org",
57+
apidocs.ParameterLocation.QUERY,
58+
description="Query param to filter by course org",
59+
)],
60+
responses={
61+
200: StudioHomeSerializer,
62+
401: "The requester is not authenticated.",
63+
},
64+
)
65+
def list(self, request: Request):
66+
"""
67+
Get an object containing all courses and libraries on home page.
68+
69+
**Example Request**
70+
71+
GET /api/contentstore/v1/home/
72+
"""
73+
home_context = get_home_context(request, True)
74+
home_context.update({
75+
# 'allow_to_create_new_org' is actually about auto-creating organizations
76+
# (e.g. when creating a course or library), so we add an additional test.
77+
'allow_to_create_new_org': (
78+
home_context['can_create_organizations'] and
79+
org_api.is_autocreate_enabled()
80+
),
81+
'studio_name': settings.STUDIO_NAME,
82+
'studio_short_name': settings.STUDIO_SHORT_NAME,
83+
'studio_request_email': settings.FEATURES.get('STUDIO_REQUEST_EMAIL', ''),
84+
'tech_support_email': settings.TECH_SUPPORT_EMAIL,
85+
'platform_name': settings.PLATFORM_NAME,
86+
'user_is_active': request.user.is_active,
87+
})
88+
serializer = self.get_serializer(home_context)
89+
return Response(serializer.data)
90+
91+
@apidocs.schema(
92+
parameters=[
93+
apidocs.string_parameter(
94+
"org",
95+
apidocs.ParameterLocation.QUERY,
96+
description="Query param to filter by course org",
97+
)],
98+
responses={
99+
200: CourseHomeTabSerializer,
100+
401: "The requester is not authenticated.",
101+
},
102+
)
103+
@action(detail=False, methods=['get'], url_path='courses', url_name='courses')
104+
def courses(self, request: Request):
105+
"""
106+
Get an object containing all courses.
107+
108+
**Example Request**
109+
110+
GET /api/contentstore/v1/home/courses/
111+
"""
112+
active_courses, archived_courses, in_process_course_actions = get_course_context(request)
113+
courses_context = {
114+
"courses": active_courses,
115+
"archived_courses": archived_courses,
116+
"in_process_course_actions": in_process_course_actions,
117+
}
118+
serializer = self.get_serializer(courses_context)
119+
return Response(serializer.data)
120+
121+
@apidocs.schema(
122+
parameters=[
123+
apidocs.string_parameter(
124+
"org",
125+
apidocs.ParameterLocation.QUERY,
126+
description="Query param to filter by course org",
127+
),
128+
apidocs.query_parameter(
129+
"is_migrated",
130+
bool,
131+
description=(
132+
"Query param to filter by migrated status of library."
133+
" If present (true or false), it will filter by migration status"
134+
" else it will return all legacy libraries."
135+
),
136+
)
137+
],
138+
responses={
139+
200: LibraryTabSerializer,
140+
401: "The requester is not authenticated.",
141+
},
142+
)
143+
@action(detail=False, methods=['get'], url_path='libraries', url_name='libraries')
144+
def libraries(self, request: Request):
145+
"""
146+
Get an object containing all libraries on home page.
147+
148+
**Example Request**
149+
150+
GET /api/contentstore/v1/home/libraries/
151+
"""
152+
library_context = get_library_context(request)
153+
serializer = self.get_serializer(library_context)
154+
return Response(serializer.data)
155+
156+
157+
# DEPRECATED (ADR 0028): Use HomeViewSet instead.
158+
# Will be removed after one named release.
159+
# Use GET home/, home/courses/, home/libraries/ instead.
16160
@view_auth_classes(is_authenticated=True)
17161
class HomePageView(APIView):
18162
"""
@@ -99,11 +243,13 @@ def get(self, request: Request):
99243
return Response(serializer.data)
100244

101245

102-
@view_auth_classes(is_authenticated=True)
103246
class HomePageCoursesView(APIView):
104247
"""
105248
View for getting all courses and libraries available to the logged in user.
106249
"""
250+
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
251+
permission_classes = (IsAuthenticated,)
252+
serializer_class = CourseHomeTabSerializer
107253
@apidocs.schema(
108254
parameters=[
109255
apidocs.string_parameter(
@@ -170,7 +316,7 @@ def get(self, request: Request):
170316
"archived_courses": archived_courses,
171317
"in_process_course_actions": in_process_course_actions,
172318
}
173-
serializer = CourseHomeTabSerializer(courses_context)
319+
serializer = self.serializer_class(courses_context)
174320
return Response(serializer.data)
175321

176322

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
import ddt
88
import pytz
99
from django.conf import settings
10-
from django.test import override_settings
10+
from django.test import TestCase, override_settings
1111
from django.urls import reverse
1212
from opaque_keys.edx.locator import LibraryLocatorV2
1313
from openedx_content import api as content_api
1414
from organizations.tests.factories import OrganizationFactory
1515
from rest_framework import status
16+
from rest_framework.test import APIClient
1617

1718
from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase
1819
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
1920
from cms.djangoapps.modulestore_migrator import api as migrator_api
2021
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
22+
from common.djangoapps.student.tests.factories import UserFactory
2123
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
2224
from openedx.core.djangoapps.content_libraries import api as lib_api
2325

@@ -400,5 +402,54 @@ def test_home_page_libraries_response(self):
400402
],
401403
}
402404

403-
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
404-
self.assertDictEqual(expected_response, response.json()) # noqa: PT009
405+
assert response.status_code == status.HTTP_200_OK
406+
assert response.json() == expected_response
407+
408+
409+
class HomePageCoursesViewPermissionsTest(TestCase):
410+
"""
411+
ADR 0026 – permission regression tests for HomePageCoursesView.
412+
413+
Verifies that the explicit permission_classes = (IsAuthenticated,) enforces
414+
the same access rules previously set by the @view_auth_classes(is_authenticated=True)
415+
decorator.
416+
"""
417+
418+
def setUp(self):
419+
super().setUp()
420+
self.client = APIClient()
421+
self.url = reverse("cms.djangoapps.contentstore:v1:courses")
422+
self.user = UserFactory.create()
423+
self.staff_user = UserFactory.create(is_staff=True)
424+
425+
def test_unauthenticated_request_returns_401(self):
426+
"""
427+
Unauthenticated request (no credentials) must be rejected with 401.
428+
429+
Before ADR 0026: enforced by @view_auth_classes(is_authenticated=True).
430+
After ADR 0026: enforced by permission_classes = (IsAuthenticated,).
431+
"""
432+
response = self.client.get(self.url)
433+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
434+
435+
def test_authenticated_user_gets_200(self):
436+
"""
437+
Any authenticated user (not necessarily staff) must receive 200.
438+
439+
HomePageCoursesView only requires authentication — no staff role needed.
440+
The view returns an empty course list for users with no assigned courses.
441+
"""
442+
self.client.force_authenticate(user=self.user)
443+
response = self.client.get(self.url)
444+
assert response.status_code == status.HTTP_200_OK
445+
446+
def test_staff_user_gets_200(self):
447+
"""Staff user must also receive 200 (staff is a superset of authenticated)."""
448+
self.client.force_authenticate(user=self.staff_user)
449+
response = self.client.get(self.url)
450+
assert response.status_code == status.HTTP_200_OK
451+
452+
def test_post_by_unauthenticated_returns_401(self):
453+
"""Non-GET methods also enforce authentication — POST without credentials is 401."""
454+
response = self.client.post(self.url, data={})
455+
assert response.status_code == status.HTTP_401_UNAUTHORIZED

0 commit comments

Comments
 (0)