diff --git a/course_discovery/apps/api/serializers.py b/course_discovery/apps/api/serializers.py index b0cff4420e..69ec718c3c 100644 --- a/course_discovery/apps/api/serializers.py +++ b/course_discovery/apps/api/serializers.py @@ -31,7 +31,7 @@ from course_discovery.apps.api.fields import ( HtmlField, ImageField, SlugRelatedFieldWithReadSerializer, SlugRelatedTranslatableField, StdImageSerializerField ) -from course_discovery.apps.api.utils import StudioAPI +from course_discovery.apps.api.utils import StudioAPI, get_excluded_restriction_types from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.core.api_client.lms import LMSAPIClient from course_discovery.apps.core.utils import update_instance @@ -1638,8 +1638,10 @@ class CourseWithRecommendationsSerializer(FlexFieldsSerializerMixin, TimestampMo recommendations = serializers.SerializerMethodField() def get_recommendations(self, course): + excluded_restriction_types = get_excluded_restriction_types(self.context['request']) + recommended_courses = course.recommendations(excluded_restriction_types=excluded_restriction_types) return CourseRecommendationSerializer( - course.recommendations(), + recommended_courses, many=True, context={ 'request': self.context.get('request'), @@ -1996,7 +1998,7 @@ def get_organization_logo_override_url(self, obj): return None @classmethod - def prefetch_queryset(cls, partner, queryset=None): + def prefetch_queryset(cls, partner, queryset=None, course_runs=None): # Explicitly check if the queryset is None before selecting related queryset = queryset if queryset is not None else Program.objects.filter(partner=partner) @@ -2020,7 +2022,7 @@ def prefetch_queryset(cls, partner, queryset=None): 'degree__rankings', 'degree__quick_facts', 'labels', - Prefetch('courses', queryset=MinimalProgramCourseSerializer.prefetch_queryset()), + Prefetch('courses', queryset=MinimalProgramCourseSerializer.prefetch_queryset(course_runs=course_runs)), Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), ) @@ -2165,8 +2167,8 @@ class MinimalExtendedProgramSerializer(MinimalProgramSerializer): expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value') @classmethod - def prefetch_queryset(cls, partner, queryset=None): - queryset = super().prefetch_queryset(partner=partner, queryset=queryset) + def prefetch_queryset(cls, partner, queryset=None, course_runs=None): + queryset = super().prefetch_queryset(partner=partner, queryset=queryset, course_runs=course_runs) return queryset.prefetch_related( 'expected_learning_items', @@ -2209,7 +2211,7 @@ class ProgramSerializer(MinimalProgramSerializer): product_source = SourceSerializer(required=False, read_only=True) @classmethod - def prefetch_queryset(cls, partner, queryset=None): + def prefetch_queryset(cls, partner, queryset=None, course_runs=None): """ Prefetch the related objects that will be serialized with a `Program`. @@ -2255,7 +2257,7 @@ def prefetch_queryset(cls, partner, queryset=None): 'instructor_ordering', # We need the full Course prefetch here to get CourseRun information that methods on the Program # model iterate across (e.g. language). These fields aren't prefetched by the minimal Course serializer. - Prefetch('courses', queryset=CourseSerializer.prefetch_queryset(partner=partner)), + Prefetch('courses', queryset=CourseSerializer.prefetch_queryset(partner=partner, course_runs=course_runs)), Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), Prefetch('credit_backing_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), Prefetch('corporate_endorsements', queryset=CorporateEndorsementSerializer.prefetch_queryset()), @@ -2302,11 +2304,13 @@ class PathwaySerializer(BaseModelSerializer): course_run_statuses = serializers.ReadOnlyField() @classmethod - def prefetch_queryset(cls, partner): + def prefetch_queryset(cls, partner, course_runs=None): queryset = Pathway.objects.filter(partner=partner) return queryset.prefetch_related( - Prefetch('programs', queryset=MinimalProgramSerializer.prefetch_queryset(partner=partner)), + Prefetch('programs', queryset=MinimalProgramSerializer.prefetch_queryset( + partner=partner, course_runs=course_runs + )), ) class Meta: diff --git a/course_discovery/apps/api/tests/test_serializers.py b/course_discovery/apps/api/tests/test_serializers.py index 29f553c19d..ec023e9adf 100644 --- a/course_discovery/apps/api/tests/test_serializers.py +++ b/course_discovery/apps/api/tests/test_serializers.py @@ -2347,7 +2347,9 @@ def test_detail_fields_in_response(self, is_post_request): 'staff': MinimalPersonSerializer(course_run.staff, many=True, context={'request': request}).data, 'content_language': course_run.language.code if course_run.language else None, - + 'restriction_type': ( + course_run.restricted_run.restriction_type if hasattr(course_run, 'restricted_run') else None + ) }], 'uuid': str(course.uuid), 'subjects': [subject.name for subject in course.subjects.all()], @@ -2418,6 +2420,9 @@ def get_expected_data(cls, course, course_run, course_skill, seat): 'estimated_hours': get_course_run_estimated_hours(course_run), 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price or 0.0, 'is_enrollable': course_run.is_enrollable, + 'restriction_type': ( + course_run.restricted_run.restriction_type if hasattr(course_run, 'restricted_run') else None + ) }], 'uuid': str(course.uuid), 'subjects': [subject.name for subject in course.subjects.all()], @@ -2549,6 +2554,9 @@ def get_expected_data(cls, course_run, course_skill, request): 'first_enrollable_paid_seat_sku': course_run.first_enrollable_paid_seat_sku(), 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price, 'is_enrollable': course_run.is_enrollable, + 'restriction_type': ( + course_run.restricted_run.restriction_type if hasattr(course_run, 'restricted_run') else None + ) } @@ -2751,7 +2759,8 @@ def get_expected_data(cls, learner_pathway, request): 'visible_via_association': True, 'steps': LearnerPathwayStepSerializer( learner_pathway.steps.all(), - many=True + many=True, + context={'request': request} ).data, 'created': serialize_datetime(learner_pathway.created), } diff --git a/course_discovery/apps/api/utils.py b/course_discovery/apps/api/utils.py index e93dd1294e..1818ef62d9 100644 --- a/course_discovery/apps/api/utils.py +++ b/course_discovery/apps/api/utils.py @@ -13,6 +13,7 @@ from course_discovery.apps.core.api_client.lms import LMSAPIClient from course_discovery.apps.core.utils import serialize_datetime +from course_discovery.apps.course_metadata.choices import CourseRunRestrictionType from course_discovery.apps.course_metadata.models import CourseRun logger = logging.getLogger(__name__) @@ -199,6 +200,11 @@ def increment_character(character): return chr(ord(character) + 1) if character != 'z' else 'a' +def get_excluded_restriction_types(request): + include_restricted = request.query_params.get('include_restricted', '').split(',') + return list(set(CourseRunRestrictionType.values) - set(include_restricted)) + + class StudioAPI: """ A convenience class for talking to the Studio API - designed to allow subclassing by the publisher django app, diff --git a/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py b/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py index c294e1384c..9a1e703358 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py @@ -21,7 +21,7 @@ from course_discovery.apps.course_metadata.choices import CourseRunStatus from course_discovery.apps.course_metadata.models import Course, CourseType from course_discovery.apps.course_metadata.tests.factories import ( - CourseRunFactory, SeatFactory, SeatTypeFactory, SubjectFactory + CourseRunFactory, RestrictedCourseRunFactory, SeatFactory, SeatTypeFactory, SubjectFactory ) from course_discovery.conftest import get_course_run_states @@ -335,6 +335,33 @@ def test_courses(self, state): assert response.status_code == 200 assert response.data['results'] == [] + @ddt.data([True, 2], [False, 1]) + @ddt.unpack + def test_courses_with_restricted_runs(self, include_restriction_param, expected_result_count): + url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id}) + Course.objects.all().delete() + + now = datetime.datetime.now(pytz.UTC) + future = now + datetime.timedelta(days=30) + course_run = CourseRunFactory.create( + course__title='ABC Test Course With Archived', end=future, enrollment_end=future + ) + restricted_course_run = CourseRunFactory.create( + course=course_run.course, + course__title='ABC Test Course With Archived', end=future, enrollment_end=future, + status=CourseRunStatus.Published + ) + RestrictedCourseRunFactory(course_run=restricted_course_run, restriction_type='custom-b2b-enterprise') + SeatFactory.create(course_run=course_run) + SeatFactory.create(course_run=restricted_course_run) + + if include_restriction_param: + url += '?include_restricted=custom-b2b-enterprise' + + response = self.client.get(url) + assert response.status_code == 200 + assert len(response.data['results'][0]['course_runs']) == expected_result_count + def test_courses_with_include_archived(self): """ Verify the endpoint returns the list of available and archived courses if include archived diff --git a/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py b/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py index f997d5b9c7..602f5a2892 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py @@ -9,6 +9,7 @@ import pytz import responses from django.contrib.auth.models import Group +from django.core.management import call_command from django.db.models.functions import Lower from django.db.models.signals import pre_save from django.test import override_settings @@ -1211,6 +1212,43 @@ def test_list_sorted_by_course_start_date(self): self.serialize_course_run(CourseRun.objects.all().order_by('start'), many=True) ) + @ddt.data(True, False) + def test_list_include_restricted(self, include_restriction_param): + restricted_run = CourseRunFactory(course__partner=self.partner) + RestrictedCourseRunFactory(course_run=restricted_run, restriction_type='custom-b2c') + url = reverse('api:v1:course_run-list') + if include_restriction_param: + url += '?include_restricted=custom-b2c' + + with self.assertNumQueries(14, threshold=3): + response = self.client.get(url) + + assert response.status_code == 200 + retrieved_keys = [r['key'] for r in response.data['results']] + if include_restriction_param: + assert restricted_run.key in retrieved_keys + else: + assert restricted_run.key not in retrieved_keys + + @ddt.data([True, 4], [False, 3]) + @ddt.unpack + def test_list_query_include_restricted(self, include_restriction_param, expected_result_count): + CourseRunFactory.create_batch(3, title='Some cool title', course__partner=self.partner) + CourseRunFactory(title='non-cool title') + restricted_run = CourseRunFactory(title='Some cool title', course__partner=self.partner) + RestrictedCourseRunFactory(course_run=restricted_run, restriction_type='custom-b2c') + query = 'title:Some cool title' + url = '{root}?q={query}'.format(root=reverse('api:v1:course_run-list'), query=query) + if include_restriction_param: + url += '&include_restricted=custom-b2c,custom-b2b-enterprise' + + call_command('search_index', '--rebuild', '-f') + + with self.assertNumQueries(30, threshold=3): + response = self.client.get(url) + + assert len(response.data['results']) == expected_result_count + def test_list_query(self): """ Verify the endpoint returns a filtered list of courses """ course_runs = CourseRunFactory.create_batch(3, title='Some random title', course__partner=self.partner) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_courses.py b/course_discovery/apps/api/v1/tests/test_views/test_courses.py index ae5a2018f6..8217afeb8a 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_courses.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_courses.py @@ -35,7 +35,7 @@ from course_discovery.apps.course_metadata.tests.factories import ( CourseEditorFactory, CourseEntitlementFactory, CourseFactory, CourseLocationRestrictionFactory, CourseRunFactory, CourseTypeFactory, GeoLocationFactory, LevelTypeFactory, OrganizationFactory, ProductValueFactory, ProgramFactory, - SeatFactory, SeatTypeFactory, SourceFactory, SubjectFactory + RestrictedCourseRunFactory, SeatFactory, SeatTypeFactory, SourceFactory, SubjectFactory ) from course_discovery.apps.course_metadata.toggles import IS_SUBDIRECTORY_SLUG_FORMAT_ENABLED from course_discovery.apps.course_metadata.utils import data_modified_timestamp_update, ensure_draft_world @@ -278,6 +278,68 @@ def test_course_runs_are_ordered(self): self.assertListEqual(response.data['course_run_keys'], expected_keys) self.assertListEqual([run['key'] for run in response.data['course_runs']], expected_keys) + @ddt.data(True, False) + def test_course_runs_restriction(self, include_restriction_param): + run_restricted = CourseRunFactory( + course=self.course, + start=datetime.datetime(2033, 1, 1, tzinfo=pytz.UTC), + status=CourseRunStatus.Published + ) + run_not_restricted = CourseRunFactory( + course=self.course, + start=datetime.datetime(2033, 1, 1, tzinfo=pytz.UTC), + status=CourseRunStatus.Unpublished + ) + RestrictedCourseRunFactory(course_run=run_restricted, restriction_type='custom-b2c') + SeatFactory(course_run=run_restricted) + SeatFactory(course_run=run_not_restricted) + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) + if include_restriction_param: + url += '?include_restricted=custom-b2c' + with self.assertNumQueries(36, threshold=3): + response = self.client.get(url) + assert response.status_code == 200 + + if not include_restriction_param: + self.assertEqual(response.data['course_run_keys'], [run_not_restricted.key]) + self.assertEqual(response.data['course_run_statuses'], [run_not_restricted.status]) + self.assertEqual(len(response.data['course_runs']), 1) + self.assertEqual(response.data['advertised_course_run_uuid'], None) + else: + self.assertEqual(set(response.data['course_run_keys']), {run_not_restricted.key, run_restricted.key}) + self.assertEqual( + set(response.data['course_run_statuses']), + {run_not_restricted.status, run_restricted.status} + ) + self.assertEqual(len(response.data['course_runs']), 2) + self.assertEqual(response.data['advertised_course_run_uuid'], run_restricted.uuid) + + def test_course_runs_restriction_param(self): + run_restricted = CourseRunFactory( + course=self.course, + start=datetime.datetime(2033, 1, 1, tzinfo=pytz.UTC), + status=CourseRunStatus.Published + ) + run_not_restricted = CourseRunFactory( + course=self.course, + start=datetime.datetime(2033, 1, 1, tzinfo=pytz.UTC), + status=CourseRunStatus.Unpublished + ) + RestrictedCourseRunFactory(course_run=run_restricted, restriction_type='custom-b2c') + SeatFactory(course_run=run_restricted) + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) + url += '?include_restricted=custom-b2c' + with self.assertNumQueries(36, threshold=3): + response = self.client.get(url) + assert response.status_code == 200 + + self.assertEqual(set(response.data['course_run_keys']), {run_not_restricted.key, run_restricted.key}) + self.assertEqual(set(response.data['course_run_statuses']), {run_not_restricted.status, run_restricted.status}) + self.assertEqual(len(response.data['course_runs']), 2) + self.assertEqual(response.data['advertised_course_run_uuid'], run_restricted.uuid) + def test_list(self): """ Verify the endpoint returns a list of all courses. """ url = reverse('api:v1:course-list') diff --git a/course_discovery/apps/api/v1/tests/test_views/test_programs.py b/course_discovery/apps/api/v1/tests/test_views/test_programs.py index a419013328..ec1e7f7a4b 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_programs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_programs.py @@ -13,13 +13,13 @@ from course_discovery.apps.api.v1.views.programs import ProgramViewSet from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.core.tests.helpers import make_image_file -from course_discovery.apps.course_metadata.choices import ProgramStatus +from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.models import CourseType, Program, ProgramType from course_discovery.apps.course_metadata.tests.factories import ( CorporateEndorsementFactory, CourseFactory, CourseRunFactory, CurriculumCourseMembershipFactory, CurriculumFactory, CurriculumProgramMembershipFactory, DegreeAdditionalMetadataFactory, DegreeFactory, EndorsementFactory, ExpectedLearningItemFactory, JobOutlookItemFactory, OrganizationFactory, PersonFactory, ProgramFactory, - ProgramTypeFactory, VideoFactory + ProgramTypeFactory, RestrictedCourseRunFactory, VideoFactory ) @@ -48,13 +48,16 @@ def setup(self, client, django_assert_num_queries, partner): self.partner = partner self.request = request - def create_program(self, courses=None, program_type=None): + def create_program(self, courses=None, program_type=None, include_restricted_run=False): organizations = [OrganizationFactory(partner=self.partner)] person = PersonFactory() if courses is None: courses = [CourseFactory(partner=self.partner)] - CourseRunFactory(course=courses[0], staff=[person]) + course_run = CourseRunFactory(course=courses[0], staff=[person]) + + if include_restricted_run: + RestrictedCourseRunFactory(course_run=course_run, restriction_type='custom-b2c') if program_type is None: program_type = ProgramTypeFactory() @@ -216,6 +219,21 @@ def test_list(self): self.assert_list_results(self.list_path, expected, 26) + @pytest.mark.parametrize("include_restriction_param", [True, False]) + def test_list_restricted_runs(self, include_restriction_param): + self.create_program(include_restricted_run=True) + query_param_string = "?include_restricted=custom-b2c" if include_restriction_param else "" + resp = self.client.get(self.list_path + query_param_string) + + if include_restriction_param: + assert resp.data['results'][0]['courses'][0]['course_runs'] + assert resp.data['results'][0]['courses'][0]['course_run_statuses'] + assert resp.data['results'][0]['course_run_statuses'] == [CourseRunStatus.Published] + else: + assert not resp.data['results'][0]['courses'][0]['course_runs'] + assert not resp.data['results'][0]['courses'][0]['course_run_statuses'] + assert resp.data['results'][0]['course_run_statuses'] == [] + def test_extended_query_param_fields(self): """ Verify that the `extended` query param will result in an extended amount of fields returned. """ for _ in range(3): diff --git a/course_discovery/apps/api/v1/tests/test_views/test_search.py b/course_discovery/apps/api/v1/tests/test_views/test_search.py index 80c86fc0ea..28ee484468 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_search.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_search.py @@ -23,8 +23,10 @@ CourseRunSearchDocumentSerializer, CourseRunSearchModelSerializer, LimitedAggregateSearchSerializer ) from course_discovery.apps.course_metadata.tests.factories import ( - CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory, ProgramFactory, SeatFactory + CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory, ProgramFactory, + RestrictedCourseRunFactory, SeatFactory, SeatTypeFactory ) +from course_discovery.apps.ietf_language_tags.utils import serialize_language from course_discovery.apps.learner_pathway.models import LearnerPathway from course_discovery.apps.learner_pathway.tests.factories import LearnerPathwayStepFactory from course_discovery.apps.publisher.tests import factories as publisher_factories @@ -120,6 +122,34 @@ def test_search(self, path, serializer): """ Verify the view returns search results. """ self.assert_successful_search(path=path, serializer=serializer) + @ddt.data( + [list_path, True], + [list_path, False], + [detailed_path, True], + [detailed_path, False] + ) + @ddt.unpack + def test_search_restricted_runs(self, path, include_restriction_param): + course_run = CourseRunFactory(course__partner=self.partner, course__title='Software Testing', + status=CourseRunStatus.Published) + + RestrictedCourseRunFactory(course_run=course_run, restriction_type='custom-b2c') + call_command('search_index', '--populate') + + if include_restriction_param: + path += '?include_restricted=custom-b2c' + + response = self.get_response('software', path=path) + + assert response.status_code == 200 + + if include_restriction_param: + assert response.data['results'] + assert response.data['count'] == 1 + else: + assert not response.data['results'] + assert response.data['count'] == 0 + def test_faceted_search(self): """ Verify the view returns results and facets. """ course_run, response_data = self.assert_successful_search(path=self.faceted_path) @@ -295,14 +325,14 @@ def setUp(self): temp_req_dict.clear() self.request.GET = temp_req_dict - def get_response(self, query=None, endpoint=None): + def get_response(self, query=None, endpoint=None, path_has_params=False): path = endpoint or self.faceted_path qs = '' if query: qs = urllib.parse.urlencode(query, True) - url = f'{path}?{qs}' + url = f'{path}&{qs}' if path_has_params else f'{path}?{qs}' return self.client.get(url) def process_response(self, response): @@ -311,6 +341,62 @@ def process_response(self, response): assert objects['count'] > 0 return objects + @ddt.data(True, False) + def test_results_restricted_runs(self, include_restriced_param): + CourseFactory( + key=self.regular_key, + title='ABCs of Ͳҽʂէìղց', + partner=self.partner + ) + course = CourseFactory( + key=self.desired_key, + title='ABCs of Ͳҽʂէìղց', + partner=self.partner + ) + course_run_restricted = CourseRunFactory( + course__partner=self.partner, + course=course, + status=CourseRunStatus.Published, + key=self.regular_key, + type__is_marketable=True + ) + SeatFactory(type=SeatTypeFactory.verified(), course_run=course_run_restricted) + RestrictedCourseRunFactory(course_run=course_run_restricted, restriction_type='custom-b2c') + course_run = CourseRunFactory( + course__partner=self.partner, + course=course, + status=CourseRunStatus.Reviewed, + key='course-v1:edx+TeamX+2024', + type__is_marketable=True + ) + SeatFactory(type=SeatTypeFactory.audit(), course_run=course_run) + + call_command('search_index', '--populate') + + endpoint = self.list_path + if include_restriced_param: + endpoint += '?include_restricted=custom-b2c' + response = self.get_response( + query={'key.raw': self.desired_key}, + endpoint=endpoint, + path_has_params=include_restriced_param + ) + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["results"]) == 1 + + if not include_restriced_param: + assert len(response_data["results"][0]["course_runs"]) == 1 + assert set(response_data["results"][0]["languages"]) == {serialize_language(course_run.language)} + assert set(response_data["results"][0]["seat_types"]) == {'audit'} + else: + assert len(response_data["results"][0]["course_runs"]) == 2 + assert set(response_data["results"][0]["languages"]) == { + serialize_language(course_run.language), serialize_language(course_run_restricted.language) + } + assert set(response_data["results"][0]["seat_types"]) == {'audit', 'verified'} + def test_results_only_include_specific_key_objects(self): """ Verify the search results only include items with 'key' set to 'course:edX+DemoX'. """ @@ -532,7 +618,7 @@ def test_results_filtered_by_default_partner(self, short_code): self.serialize_program_search(other_program), ] - @ddt.data((True, 7), (False, 8)) + @ddt.data((True, 8), (False, 9)) @ddt.unpack def test_query_count_exclude_expired_course_run(self, exclude_expired, expected_queries): """ Verify that there is no query explosion when excluding expired course runs. """ diff --git a/course_discovery/apps/api/v1/views/catalogs.py b/course_discovery/apps/api/v1/views/catalogs.py index 631b99652e..f6501ae1bc 100644 --- a/course_discovery/apps/api/v1/views/catalogs.py +++ b/course_discovery/apps/api/v1/views/catalogs.py @@ -11,7 +11,7 @@ from course_discovery.apps.api import filters, serializers from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.renderers import CourseRunCSVRenderer -from course_discovery.apps.api.utils import check_catalog_api_access +from course_discovery.apps.api.utils import check_catalog_api_access, get_excluded_restriction_types from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.course_metadata.models import CourseRun, CourseType @@ -105,7 +105,8 @@ def courses(self, request, id=None): # pylint: disable=redefined-builtin, unuse if not catalog.include_archived: queryset = queryset.available() course_runs = course_runs.active().enrollable().marketable() - + excluded_restriction_types = get_excluded_restriction_types(request) + course_runs = course_runs.exclude(restricted_run__restriction_type__in=excluded_restriction_types) queryset = serializers.CatalogCourseSerializer.prefetch_queryset( self.request.site.partner, queryset=queryset, diff --git a/course_discovery/apps/api/v1/views/course_runs.py b/course_discovery/apps/api/v1/views/course_runs.py index cb8fe47bac..f9283b5dce 100644 --- a/course_discovery/apps/api/v1/views/course_runs.py +++ b/course_discovery/apps/api/v1/views/course_runs.py @@ -19,7 +19,9 @@ from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.permissions import IsCourseRunEditorOrDjangoOrReadOnly from course_discovery.apps.api.serializers import MetadataWithRelatedChoices -from course_discovery.apps.api.utils import StudioAPI, get_query_param, reviewable_data_has_changed +from course_discovery.apps.api.utils import ( + StudioAPI, get_excluded_restriction_types, get_query_param, reviewable_data_has_changed +) from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported from course_discovery.apps.core.utils import SearchQuerySetWrapper from course_discovery.apps.course_metadata.choices import CourseRunStatus @@ -83,6 +85,7 @@ def get_queryset(self): q = self.request.query_params.get('q') partner = self.request.site.partner edit_mode = get_query_param(self.request, 'editable') or self.request.method not in SAFE_METHODS + excluded_restriction_types = get_excluded_restriction_types(self.request) if edit_mode and q: raise EditableAndQUnsupported() @@ -96,9 +99,13 @@ def get_queryset(self): else: queryset = self.queryset + if self.request.method == 'GET': + queryset = queryset.exclude(restricted_run__restriction_type__in=excluded_restriction_types) if q: queryset = SearchQuerySetWrapper( - CourseRun.search(q).filter('term', partner=partner.short_code), + CourseRun.search(q).filter('term', partner=partner.short_code).exclude( + 'terms', restriction_type=excluded_restriction_types + ), model=queryset.model ) else: diff --git a/course_discovery/apps/api/v1/views/courses.py b/course_discovery/apps/api/v1/views/courses.py index ed797d8764..931d2dc886 100644 --- a/course_discovery/apps/api/v1/views/courses.py +++ b/course_discovery/apps/api/v1/views/courses.py @@ -23,7 +23,9 @@ from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.permissions import IsCourseEditorOrReadOnly from course_discovery.apps.api.serializers import CourseEntitlementSerializer, MetadataWithType -from course_discovery.apps.api.utils import decode_image_data, get_query_param, reviewable_data_has_changed +from course_discovery.apps.api.utils import ( + decode_image_data, get_excluded_restriction_types, get_query_param, reviewable_data_has_changed +) from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported from course_discovery.apps.api.v1.views.course_runs import CourseRunViewSet from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus @@ -122,9 +124,13 @@ def get_queryset(self): else: queryset = self.queryset + excluded_restriction_types = get_excluded_restriction_types(self.request) if q: queryset = Course.search(q, queryset=queryset) - queryset = self.get_serializer_class().prefetch_queryset(queryset=queryset, partner=partner) + course_runs = CourseRun.objects.exclude(restricted_run__restriction_type__in=excluded_restriction_types) + queryset = self.get_serializer_class().prefetch_queryset( + queryset=queryset, partner=partner, course_runs=course_runs + ) else: if edit_mode: course_runs = CourseRun.objects.filter_drafts(course__partner=partner) @@ -148,6 +154,7 @@ def get_queryset(self): else: programs = Program.objects.exclude(status=ProgramStatus.Deleted) + course_runs = course_runs.exclude(restricted_run__restriction_type__in=excluded_restriction_types) queryset = self.get_serializer_class().prefetch_queryset( queryset=queryset, course_runs=course_runs, diff --git a/course_discovery/apps/api/v1/views/pathways.py b/course_discovery/apps/api/v1/views/pathways.py index c428a0074c..5cbde6d66c 100644 --- a/course_discovery/apps/api/v1/views/pathways.py +++ b/course_discovery/apps/api/v1/views/pathways.py @@ -4,6 +4,8 @@ from course_discovery.apps.api import serializers from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.permissions import ReadOnlyByPublisherUser +from course_discovery.apps.api.utils import get_excluded_restriction_types +from course_discovery.apps.course_metadata.models import CourseRun class PathwayViewSet(CompressedCacheResponseMixin, viewsets.ReadOnlyModelViewSet): @@ -11,5 +13,11 @@ class PathwayViewSet(CompressedCacheResponseMixin, viewsets.ReadOnlyModelViewSet serializer_class = serializers.PathwaySerializer def get_queryset(self): - queryset = self.get_serializer_class().prefetch_queryset(partner=self.request.site.partner) + excluded_restriction_types = get_excluded_restriction_types(self.request) + course_runs = CourseRun.objects.exclude(restricted_run__restriction_type__in=excluded_restriction_types) + + queryset = self.get_serializer_class().prefetch_queryset( + partner=self.request.site.partner, + course_runs=course_runs + ) return queryset.order_by('created') diff --git a/course_discovery/apps/api/v1/views/programs.py b/course_discovery/apps/api/v1/views/programs.py index 575e920915..43a325730f 100644 --- a/course_discovery/apps/api/v1/views/programs.py +++ b/course_discovery/apps/api/v1/views/programs.py @@ -12,8 +12,8 @@ from course_discovery.apps.api import filters, serializers from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import ProxiedPagination -from course_discovery.apps.api.utils import get_query_param -from course_discovery.apps.course_metadata.models import Program +from course_discovery.apps.api.utils import get_excluded_restriction_types, get_query_param +from course_discovery.apps.course_metadata.models import CourseRun, Program class ProgramViewSet(CompressedCacheResponseMixin, viewsets.ReadOnlyModelViewSet): @@ -46,7 +46,14 @@ def get_queryset(self): queryset = Program.objects.filter(uuid=program_uuid) elif q: queryset = Program.search(q, queryset=queryset) - return self.get_serializer_class().prefetch_queryset(queryset=queryset, partner=partner) + + excluded_restriction_types = get_excluded_restriction_types(self.request) + course_runs = CourseRun.objects.exclude(restricted_run__restriction_type__in=excluded_restriction_types) + return self.get_serializer_class().prefetch_queryset( + queryset=queryset, + partner=partner, + course_runs=course_runs + ) def get_serializer_context(self): context = super().get_serializer_context() diff --git a/course_discovery/apps/api/v1/views/search.py b/course_discovery/apps/api/v1/views/search.py index f2fbd70fa2..fb1e7a0095 100644 --- a/course_discovery/apps/api/v1/views/search.py +++ b/course_discovery/apps/api/v1/views/search.py @@ -16,7 +16,7 @@ from rest_framework.views import APIView from course_discovery.apps.api import serializers -from course_discovery.apps.api.utils import update_query_params_with_body_data +from course_discovery.apps.api.utils import get_excluded_restriction_types, update_query_params_with_body_data from course_discovery.apps.course_metadata.choices import ProgramStatus from course_discovery.apps.course_metadata.models import Person from course_discovery.apps.course_metadata.search_indexes import documents as search_documents @@ -132,6 +132,12 @@ class CourseRunSearchViewSet(FacetQueryFieldsMixin, BaseElasticsearchDocumentVie }, } + def get_queryset(self): + queryset = super().get_queryset() + excluded_restriction_types = get_excluded_restriction_types(self.request) + queryset = queryset.exclude('terms', restriction_type=excluded_restriction_types) + return queryset + class ProgramSearchViewSet(BaseElasticsearchDocumentViewSet): """ @@ -300,6 +306,8 @@ def get_queryset(self): if not query_params.get(LEARNER_PATHWAY_FEATURE_PARAM, 'false').lower() == 'true': queryset = queryset.exclude('term', content_type=LearnerPathway.__name__.lower()) + excluded_restriction_types = get_excluded_restriction_types(self.request) + queryset = queryset.exclude('terms', restriction_type=excluded_restriction_types) return queryset @update_query_params_with_body_data @@ -373,6 +381,7 @@ def get(self, request, *_args, **_kwargs): type: List of string """ query = request.query_params.get('q') + if not query: raise ValidationError("The 'q' querystring parameter is required for searching.") words = query.split() diff --git a/course_discovery/apps/course_metadata/algolia_models.py b/course_discovery/apps/course_metadata/algolia_models.py index 3bdead98af..6945ce87a6 100644 --- a/course_discovery/apps/course_metadata/algolia_models.py +++ b/course_discovery/apps/course_metadata/algolia_models.py @@ -12,7 +12,7 @@ from course_discovery.apps.course_metadata.choices import CourseRunStatus, ExternalProductStatus, ProgramStatus from course_discovery.apps.course_metadata.models import ( - AbstractLocationRestrictionModel, Course, CourseType, ProductValue, Program, ProgramType + AbstractLocationRestrictionModel, Course, CourseRun, CourseType, ProductValue, Program, ProgramType ) from course_discovery.apps.course_metadata.utils import transform_skills_data @@ -106,7 +106,8 @@ def _wrap(self, *args, **kwargs): def get_course_availability(course): - all_runs = course.course_runs.filter(status=CourseRunStatus.Published) + all_runs = course.course_runs.all() + all_runs = filter(lambda r: r.status == CourseRunStatus.Published, all_runs) availability = set() for course_run in all_runs: @@ -230,6 +231,14 @@ class AlgoliaProxyCourse(Course, AlgoliaBasicModelFieldsMixin): class Meta: proxy = True + @classmethod + def prefetch_queryset(cls): + return cls.objects.all().prefetch_related( + models.Prefetch( + 'course_runs', queryset=CourseRun.objects.filter(restricted_run__isnull=True) + ) + ) + @property def product_type(self): if self.type.slug == CourseType.EXECUTIVE_EDUCATION_2U: @@ -470,6 +479,14 @@ class AlgoliaProxyProgram(Program, AlgoliaBasicModelFieldsMixin): class Meta: proxy = True + @classmethod + def prefetch_queryset(cls): + return cls.objects.all().prefetch_related( + models.Prefetch( + 'courses__course_runs', queryset=CourseRun.objects.filter(restricted_run__isnull=True) + ) + ) + @property def product_type(self): if self.is_2u_degree_program: diff --git a/course_discovery/apps/course_metadata/index.py b/course_discovery/apps/course_metadata/index.py index 67baef3d5d..b924f3123a 100644 --- a/course_discovery/apps/course_metadata/index.py +++ b/course_discovery/apps/course_metadata/index.py @@ -23,11 +23,11 @@ def get_queryset(self): # pragma: no cover bootcamp_contentful_data = fetch_and_transform_bootcamp_contentful_data() qs1 = [AlgoliaProxyProduct(course, self.language, contentful_data=bootcamp_contentful_data) - for course in AlgoliaProxyCourse.objects.all()] + for course in AlgoliaProxyCourse.prefetch_queryset()] degree_contentful_data = fetch_and_transform_degree_contentful_data() qs2 = [AlgoliaProxyProduct(program, self.language, contentful_data=degree_contentful_data) - for program in AlgoliaProxyProgram.objects.all()] + for program in AlgoliaProxyProgram.prefetch_queryset()] return qs1 + qs2 diff --git a/course_discovery/apps/course_metadata/models.py b/course_discovery/apps/course_metadata/models.py index 314f557074..0c4cdc1beb 100644 --- a/course_discovery/apps/course_metadata/models.py +++ b/course_discovery/apps/course_metadata/models.py @@ -1909,20 +1909,25 @@ def advertised_course_run(self): def has_marketable_run(self): return any(run.is_marketable for run in self.course_runs.all()) - def recommendations(self): + def recommendations(self, excluded_restriction_types=None): """ Recommended set of courses for upsell after finishing a course. Returns de-duped list of Courses that: A) belong in the same program as given Course B) share the same subject AND same organization (or at least one) in priority of A over B """ + if excluded_restriction_types is None: + excluded_restriction_types = [] + program_courses = list( Course.objects.filter( programs__in=self.programs.all() ) .select_related('partner', 'type') .prefetch_related( - Prefetch('course_runs', queryset=CourseRun.objects.select_related('type').prefetch_related('seats')), + Prefetch('course_runs', queryset=CourseRun.objects.exclude( + restricted_run__restriction_type__in=excluded_restriction_types + ).select_related('type').prefetch_related('seats')), 'authoring_organizations', '_official_version' ) @@ -1937,7 +1942,9 @@ def recommendations(self): ) .select_related('partner', 'type') .prefetch_related( - Prefetch('course_runs', queryset=CourseRun.objects.select_related('type').prefetch_related('seats')), + Prefetch('course_runs', queryset=CourseRun.objects.exclude( + restricted_run__restriction_type__in=excluded_restriction_types + ).select_related('type').prefetch_related('seats')), 'authoring_organizations', '_official_version' ) diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/course.py b/course_discovery/apps/course_metadata/search_indexes/documents/course.py index 3e2c5ae5a3..12d6ad2e6e 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/course.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/course.py @@ -1,10 +1,11 @@ from django.conf import settings +from django.db.models import Prefetch from django_elasticsearch_dsl import Index, fields from opaque_keys.edx.keys import CourseKey from taxonomy.choices import ProductTypes from taxonomy.utils import get_whitelisted_serialized_skills -from course_discovery.apps.course_metadata.models import Course +from course_discovery.apps.course_metadata.models import Course, CourseRun from course_discovery.apps.course_metadata.utils import get_product_skill_names from .analyzers import case_insensitive_keyword @@ -122,9 +123,17 @@ def prepare_partner(self, obj): def prepare_prerequisites(self, obj): return [prerequisite.name for prerequisite in obj.prerequisites.all()] - def get_queryset(self): + def get_queryset(self, excluded_restriction_types=None): + if excluded_restriction_types is None: + excluded_restriction_types = [] + return super().get_queryset().prefetch_related( - 'course_runs__seats__type', 'course_runs__type', 'course_runs__language').select_related('partner') + Prefetch('course_runs', queryset=CourseRun.objects.exclude( + restricted_run__restriction_type__in=excluded_restriction_types + ).prefetch_related( + 'seats__type', 'type', 'language', 'restricted_run', + )) + ).select_related('partner') def prepare_course_type(self, obj): return obj.type.slug diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/course_run.py b/course_discovery/apps/course_metadata/search_indexes/documents/course_run.py index e85df5f990..51a8072652 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/course_run.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/course_run.py @@ -65,6 +65,7 @@ class CourseRunDocument(BaseCourseDocument): }) status = fields.KeywordField() start = fields.DateField() + restriction_type = fields.KeywordField() slug = fields.TextField() staff_uuids = fields.KeywordField(multi=True) type = fields.TextField( @@ -125,6 +126,11 @@ def prepare_seat_types(self, obj): def prepare_skill_names(self, obj): return get_product_skill_names(obj.course.key, ProductTypes.Course) + def prepare_restriction_type(self, obj): + if hasattr(obj, "restricted_run"): + return obj.restricted_run.restriction_type + return None + def prepare_skills(self, obj): return get_whitelisted_serialized_skills(obj.course.key, product_type=ProductTypes.Course) @@ -137,7 +143,7 @@ def prepare_transcript_languages(self, obj): for language in obj.transcript_languages.all() ] - def get_queryset(self): + def get_queryset(self, excluded_restriction_types=None): # pylint: disable=unused-argument return filter_visible_runs( super().get_queryset() .select_related('course') diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/learner_pathway.py b/course_discovery/apps/course_metadata/search_indexes/documents/learner_pathway.py index 432d34354c..bda4f549f2 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/learner_pathway.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/learner_pathway.py @@ -50,10 +50,10 @@ def prepare_partner(self, obj): def prepare_published(self, obj): return obj.status == PathwayStatus.Active - def get_queryset(self): + def get_queryset(self, excluded_restriction_types=None): # pylint: disable=unused-argument return super().get_queryset().prefetch_related( 'steps', 'steps__learnerpathwaycourse_set', 'steps__learnerpathwayprogram_set', - 'steps__learnerpathwayblock_set' + 'steps__learnerpathwayblock_set', ) def prepare_skill_names(self, obj): diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/person.py b/course_discovery/apps/course_metadata/search_indexes/documents/person.py index 2394065a9a..03a9a41e57 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/person.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/person.py @@ -47,7 +47,7 @@ def prepare_position(self, obj): return [] return [position.title, position.organization_override] - def get_queryset(self): + def get_queryset(self, excluded_restriction_types=None): # pylint: disable=unused-argument return super().get_queryset().select_related('bio_language') class Django: diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/program.py b/course_discovery/apps/course_metadata/search_indexes/documents/program.py index 359949eb83..4c1e29a0f3 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/program.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/program.py @@ -1,10 +1,11 @@ from django.conf import settings +from django.db.models import Prefetch from django_elasticsearch_dsl import Index, fields from taxonomy.choices import ProductTypes from taxonomy.utils import get_whitelisted_serialized_skills from course_discovery.apps.course_metadata.choices import ProgramStatus -from course_discovery.apps.course_metadata.models import Degree, Program +from course_discovery.apps.course_metadata.models import Course, CourseRun, Degree, Program from course_discovery.apps.course_metadata.utils import get_product_skill_names from .analyzers import case_insensitive_keyword, edge_ngram_completion, html_strip, synonym_text @@ -121,8 +122,17 @@ def prepare_staff_uuids(self, obj): def prepare_type(self, obj): return obj.type.name_t - def get_queryset(self): - return super().get_queryset().select_related('type').select_related('partner') + def get_queryset(self, excluded_restriction_types=None): + if excluded_restriction_types is None: + excluded_restriction_types = [] + + return super().get_queryset().select_related('type').select_related('partner').prefetch_related( + Prefetch('courses', queryset=Course.objects.all().prefetch_related( + Prefetch('course_runs', queryset=CourseRun.objects.exclude( + restricted_run__restriction_type__in=excluded_restriction_types + )) + )) + ) class Django: """ diff --git a/course_discovery/apps/course_metadata/search_indexes/serializers/common.py b/course_discovery/apps/course_metadata/search_indexes/serializers/common.py index 9c57716824..a61640006b 100644 --- a/course_discovery/apps/course_metadata/search_indexes/serializers/common.py +++ b/course_discovery/apps/course_metadata/search_indexes/serializers/common.py @@ -4,6 +4,7 @@ from django.utils.dateparse import parse_datetime from django_elasticsearch_dsl.registries import registry +from course_discovery.apps.api.utils import get_excluded_restriction_types from course_discovery.apps.core.utils import ElasticsearchUtils, serialize_datetime log = logging.getLogger(__name__) @@ -30,6 +31,9 @@ def get_model_object_by_instances(self, instances): Provide Model objects by elasticsearch response instances. Fetches all the incoming instances at once and returns model queryset. """ + + excluded_restriction_types = get_excluded_restriction_types(self.context['request']) + if not isinstance(instances, list): instances = [instances] document = None @@ -48,7 +52,9 @@ def get_model_object_by_instances(self, instances): if document and es_pks: try: - _objects = document(hit).get_queryset().filter(pk__in=es_pks) + _objects = document(hit).get_queryset( + excluded_restriction_types=excluded_restriction_types + ).filter(pk__in=es_pks) except ObjectDoesNotExist: log.error("Object could not be found in database for SearchResult '%r'.", self) diff --git a/course_discovery/apps/course_metadata/search_indexes/serializers/course.py b/course_discovery/apps/course_metadata/search_indexes/serializers/course.py index 317441ef01..60379287d2 100644 --- a/course_discovery/apps/course_metadata/search_indexes/serializers/course.py +++ b/course_discovery/apps/course_metadata/search_indexes/serializers/course.py @@ -71,6 +71,9 @@ def course_run_detail(self, request, detail_fields, course_run): 'estimated_hours': get_course_run_estimated_hours(course_run), 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price or 0.0, 'is_enrollable': course_run.is_enrollable, + 'restriction_type': ( + course_run.restricted_run.restriction_type if hasattr(course_run, 'restricted_run') else None + ), } if detail_fields: course_run_detail.update( @@ -228,7 +231,6 @@ class CourseSearchModelSerializer(DocumentDSLSerializerMixin, ContentTypeSeriali """ Serializer for course model elasticsearch document. """ - class Meta(CourseWithProgramsSerializer.Meta): document = CourseDocument fields = ContentTypeSerializer.Meta.fields + CourseWithProgramsSerializer.Meta.fields diff --git a/course_discovery/apps/course_metadata/search_indexes/serializers/course_run.py b/course_discovery/apps/course_metadata/search_indexes/serializers/course_run.py index c7f23279be..1878915293 100644 --- a/course_discovery/apps/course_metadata/search_indexes/serializers/course_run.py +++ b/course_discovery/apps/course_metadata/search_indexes/serializers/course_run.py @@ -90,6 +90,7 @@ class Meta: 'transcript_languages', 'type', 'weeks_to_complete', + 'restriction_type', ) diff --git a/course_discovery/apps/course_metadata/tests/test_algolia_models.py b/course_discovery/apps/course_metadata/tests/test_algolia_models.py index e4dd1d5af1..6a5b5691f7 100644 --- a/course_discovery/apps/course_metadata/tests/test_algolia_models.py +++ b/course_discovery/apps/course_metadata/tests/test_algolia_models.py @@ -17,8 +17,8 @@ from course_discovery.apps.course_metadata.tests.factories import ( AdditionalMetadataFactory, CourseFactory, CourseRunFactory, CourseTypeFactory, DegreeAdditionalMetadataFactory, DegreeFactory, GeoLocationFactory, LevelTypeFactory, OrganizationFactory, ProductMetaFactory, ProgramFactory, - ProgramSubscriptionFactory, ProgramSubscriptionPriceFactory, ProgramTypeFactory, SeatFactory, SeatTypeFactory, - SourceFactory, SubjectFactory, VideoFactory + ProgramSubscriptionFactory, ProgramSubscriptionPriceFactory, ProgramTypeFactory, RestrictedCourseRunFactory, + SeatFactory, SeatTypeFactory, SourceFactory, SubjectFactory, VideoFactory ) from course_discovery.apps.ietf_language_tags.models import LanguageTag @@ -200,7 +200,7 @@ def setUpClass(cls): Partner.objects.all().delete() Site.objects.all().delete() cls.site = SiteFactory(id=settings.SITE_ID, domain=TEST_DOMAIN) - cls.edxPartner = PartnerFactory(site=cls.site) + cls.edxPartner = PartnerFactory(site=cls.site, name='edX') cls.edxPartner.name = 'edX' @@ -213,6 +213,17 @@ def test_should_index(self): course.authoring_organizations.add(OrganizationFactory()) assert course.should_index + def test_restricted_run_should_not_index(self): + """ + Test that if a course has only one run and that run is restricted, + it will not be indexed + """ + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory()) + RestrictedCourseRunFactory(course_run=course.course_runs.first(), restriction_type="custom-b2b-enterprise") + qs = AlgoliaProxyCourse.prefetch_queryset() + assert not qs.first().should_index + def test_do_not_index_if_no_owners(self): course = self.create_course_with_basic_active_course_run() assert not course.should_index @@ -639,6 +650,17 @@ def test_program_not_available_if_no_published_runs(self): assert program.availability_level == [] + def test_program_availability_if_restricted_runs(self): + ''' + Test that program availability calculation ignores restricted runs + ''' + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + run = self.attach_published_course_run(course=course, run_type="current and ends within two weeks") + program.courses.add(course) + RestrictedCourseRunFactory(course_run=run, restriction_type='custom-b2b-enterprise') + assert AlgoliaProxyProgram.prefetch_queryset().first().availability_level == [] + def test_only_programs_with_spanish_courses_promoted_in_spanish_index(self): all_spanish_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, language_override=None) mixed_language_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, language_override=None) diff --git a/course_discovery/apps/learner_pathway/api/serializers.py b/course_discovery/apps/learner_pathway/api/serializers.py index b6c0169c48..a33724b831 100644 --- a/course_discovery/apps/learner_pathway/api/serializers.py +++ b/course_discovery/apps/learner_pathway/api/serializers.py @@ -3,6 +3,7 @@ """ from rest_framework import serializers +from course_discovery.apps.api.utils import get_excluded_restriction_types from course_discovery.apps.course_metadata.choices import CourseRunStatus from course_discovery.apps.learner_pathway import models @@ -19,7 +20,12 @@ class Meta: fields = ('key', 'course_runs') def get_course_runs(self, obj): - return list(obj.course.course_runs.filter(status=CourseRunStatus.Published).values('key')) + excluded_restriction_types = get_excluded_restriction_types(self.context['request']) + return list(obj.course.course_runs.filter( + status=CourseRunStatus.Published + ).exclude( + restricted_run__restriction_type__in=excluded_restriction_types + ).values('key')) class LearnerPathwayCourseSerializer(LearnerPathwayCourseMinimalSerializer): @@ -81,7 +87,8 @@ def get_card_image_url(self, step): return program.card_image_url def get_courses(self, obj): - return obj.get_linked_courses_and_course_runs() + excluded_restriction_types = get_excluded_restriction_types(self.context['request']) + return obj.get_linked_courses_and_course_runs(excluded_restriction_types=excluded_restriction_types) class LearnerPathwayBlockSerializer(serializers.ModelSerializer): diff --git a/course_discovery/apps/learner_pathway/api/v1/tests/test_views.py b/course_discovery/apps/learner_pathway/api/v1/tests/test_views.py index 071e7d9518..2e3da2f3cc 100644 --- a/course_discovery/apps/learner_pathway/api/v1/tests/test_views.py +++ b/course_discovery/apps/learner_pathway/api/v1/tests/test_views.py @@ -2,13 +2,11 @@ import ddt from django.test import Client, TestCase -from django.urls import reverse from pytest import mark from rest_framework import status -from course_discovery.apps import learner_pathway from course_discovery.apps.core.tests.factories import UserFactory -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, RestrictedCourseRunFactory from course_discovery.apps.learner_pathway.choices import PathwayStatus from course_discovery.apps.learner_pathway.tests.factories import ( LearnerPathwayCourseFactory, LearnerPathwayFactory, LearnerPathwayProgramFactory, LearnerPathwayStepFactory @@ -90,7 +88,7 @@ def setUp(self): course__title=LEARNER_PATHWAY_DATA['steps'][0]['courses'][0]['title'], course__short_description=LEARNER_PATHWAY_DATA['steps'][0]['courses'][0]['short_description'], ) - __ = CourseRunFactory( + self.learner_pathway_course__course_run = CourseRunFactory( course=self.learner_pathway_course.course, key=LEARNER_PATHWAY_DATA['steps'][0]['courses'][0]['course_runs'][0]['key'], status='published', @@ -170,6 +168,23 @@ def test_learner_pathway_api_filtering(self): assert data['results'][0]['uuid'] == self.learner_pathway.uuid assert data['results'][1]['uuid'] == another_learner_pathway.uuid + @ddt.data([True, 2], [False, 1]) + @ddt.unpack + def test_learner_pathway_restricted_runs(self, add_restriction_param, expected_run_count): + restricted_run = CourseRunFactory( + course=self.learner_pathway_course.course, + key='course-v1:AA+AA101+3T2024', + status='published', + ) + RestrictedCourseRunFactory(course_run=restricted_run, restriction_type='custom-b2c') + url = '/api/v1/learner-pathway/' + if add_restriction_param: + url += '?include_restricted=custom-b2c' + + api_response = self.client.get(url) + data = api_response.json() + assert len(data['results'][0]['steps'][0]['courses'][0]['course_runs']) == expected_run_count + def test_learner_pathway_api_returns_active_pathway_only(self): """ Verify that learner pathway api returns active pathway only. diff --git a/course_discovery/apps/learner_pathway/api/v1/views.py b/course_discovery/apps/learner_pathway/api/v1/views.py index 5deed0923b..5e8031fb6a 100644 --- a/course_discovery/apps/learner_pathway/api/v1/views.py +++ b/course_discovery/apps/learner_pathway/api/v1/views.py @@ -34,7 +34,7 @@ class LearnerPathwayViewSet(ReadOnlyModelViewSet): @action(detail=True) def snapshot(self, request, uuid): pathway = get_object_or_404(self.queryset, uuid=uuid, status=PathwayStatus.Active.value) - serializer = serializers.LearnerPathwaySerializer(pathway, many=False) + serializer = serializers.LearnerPathwaySerializer(pathway, many=False, context={'request': self.request}) return Response(data=serializer.data, status=status.HTTP_200_OK) @action(detail=False) diff --git a/course_discovery/apps/learner_pathway/models.py b/course_discovery/apps/learner_pathway/models.py index 1278dbe7e1..7278f612a3 100644 --- a/course_discovery/apps/learner_pathway/models.py +++ b/course_discovery/apps/learner_pathway/models.py @@ -299,13 +299,22 @@ def get_skills(self) -> [str]: return program_skills - def get_linked_courses_and_course_runs(self) -> [dict]: + def get_linked_courses_and_course_runs(self, excluded_restriction_types=None) -> [dict]: """ Returns list of dict where each dict contains a course key linked with program and all its course runs """ + if excluded_restriction_types is None: + excluded_restriction_types = [] + courses = [] for course in self.program.courses.all(): - course_runs = list(course.course_runs.filter(status=CourseRunStatus.Published).values('key')) + course_runs = list( + course.course_runs.filter( + status=CourseRunStatus.Published + ).exclude( + restricted_run__restriction_type__in=excluded_restriction_types + ).values('key') + ) courses.append({"key": course.key, "course_runs": course_runs}) return courses