Skip to content

Commit

Permalink
Merge branch 'master' into hamza/PROD-4266
Browse files Browse the repository at this point in the history
  • Loading branch information
hamza-56 authored Jan 9, 2025
2 parents 670bb46 + 8bf269c commit ef8782e
Show file tree
Hide file tree
Showing 35 changed files with 1,060 additions and 236 deletions.
5 changes: 4 additions & 1 deletion course_discovery/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytz
import waffle # lint-amnesty, pylint: disable=invalid-django-waffle-import
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Count
from django.db.models.query import Prefetch
Expand Down Expand Up @@ -2742,7 +2743,9 @@ def create_type_options(self, info):
],
} for course_type in CourseType.objects.prefetch_related(
'course_run_types__tracks__mode', 'entitlement_types', 'white_listed_orgs'
).exclude(slug=CourseType.EMPTY) if not course_type.white_listed_orgs.exists() or user.is_staff or
).exclude(
slug__in=[CourseType.EMPTY, *settings.RETIRED_COURSE_TYPES]
) if not course_type.white_listed_orgs.exists() or user.is_staff or
user.groups.filter(organization_extension__organization__in=course_type.white_listed_orgs.all()).exists()]
return info

Expand Down
30 changes: 29 additions & 1 deletion course_discovery/apps/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import math
from urllib.parse import parse_qsl, urlencode, urljoin

from django.conf import settings
from django.core.files.base import ContentFile
from django.db.models.fields.related import ManyToManyField
from django.utils.translation import gettext as _
Expand All @@ -15,7 +16,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
from course_discovery.apps.course_metadata.models import CourseRun, CourseRunType, CourseType

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -362,6 +363,19 @@ def push_to_studio(self, course_run, create=False, old_course_run_key=None, user
else:
self.update_course_run_details_in_studio(course_run)

def _update_end_date_in_studio(self, course_run):
"""
We do not update end dates in studio after creation. However, while archiving a course, we
may wish to set the end date in the past. This method serves that purpose.
"""
data = self.generate_data_for_studio_api(course_run, creating=False)
data.setdefault('schedule', {})
for attr in ['end', 'enrollment_end']:
attr_val = getattr(course_run, attr)
if attr_val:
data['schedule'][attr] = serialize_datetime(attr_val)
self._request('patch', f'course_runs/{course_run.key}/', json=data)


def use_request_cache(cache_name, key_func):
"""
Expand Down Expand Up @@ -399,3 +413,17 @@ def wrapper(*args, **kwargs):
return result
return wrapper
return inner


@use_request_cache("retired_run_types_cache", lambda: "retired_run_types")
def get_retired_run_type_ids():
return list(
CourseRunType.objects.filter(slug__in=settings.RETIRED_RUN_TYPES).values_list('id', flat=True)
)


@use_request_cache("retired_course_types_cache", lambda: "retired_course_types")
def get_retired_course_type_ids():
return list(
CourseType.objects.filter(slug__in=settings.RETIRED_COURSE_TYPES).values_list('id', flat=True)
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import datetime
import urllib
import uuid
Expand Down Expand Up @@ -103,6 +104,25 @@ def test_get(self):
assert response.status_code == 200
assert response.data == self.serialize_course_run(self.course_run)

@ddt.data(
[True, 200],
[False, 404]
)
@ddt.unpack
def test_get_filters_retired(self, include_retired, status_code):
""" Verify the endpoint excludes retired run types by default. """
bootcamp_type, _ = CourseRunType.objects.get_or_create(slug=CourseRunType.PAID_BOOTCAMP)
run = CourseRunFactory(course__partner=self.partner, type=bootcamp_type)
url = reverse('api:v1:course_run-detail', kwargs={'key': run.key})
if include_retired:
url += '?include_retired_run_types=1'

context = self.assertNumQueries(15, threshold=3) if include_retired else contextlib.nullcontext(0)
with context:
response = self.client.get(url)

assert response.status_code == status_code

def test_get_exclude_deleted_programs(self):
""" Verify the endpoint returns no associated deleted programs """
ProgramFactory(courses=[self.course_run.course], status=ProgramStatus.Deleted)
Expand Down Expand Up @@ -1208,6 +1228,24 @@ def test_list(self):
self.serialize_course_run(CourseRun.objects.all().order_by(Lower('key')), many=True)
)

@ddt.data(
[True, 3],
[False, 2]
)
@ddt.unpack
def test_list_filters_retired(self, include_retired, expected_length):
""" Verify the endpoint excludes retired types by default. """
bootcamp_type, _ = CourseRunType.objects.get_or_create(slug=CourseRunType.PAID_BOOTCAMP)
CourseRunFactory(course__partner=self.partner, type=bootcamp_type)
url = reverse('api:v1:course_run-list')
if include_retired:
url += '?include_retired_run_types=1'

response = self.client.get(url)

assert response.status_code == 200
assert len(response.data['results']) == expected_length

def test_list_sorted_by_course_start_date(self):
""" Verify the endpoint returns a list of all course runs sorted by start date. """
url = '{root}?ordering=start'.format(root=reverse('api:v1:course_run-list'))
Expand Down Expand Up @@ -1269,7 +1307,7 @@ def test_list_query(self):
query = 'title:Some random title'
url = '{root}?q={query}'.format(root=reverse('api:v1:course_run-list'), query=query)

with self.assertNumQueries(25, threshold=3):
with self.assertNumQueries(26, threshold=3):
response = self.client.get(url)

actual_sorted = sorted(response.data['results'], key=lambda course_run: course_run['key'])
Expand Down
55 changes: 50 additions & 5 deletions course_discovery/apps/api/v1/tests/test_views/test_courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,27 @@ def test_get(self):
assert response.status_code == 200
assert response.data == self.serialize_course(self.course)

@ddt.data(
[True, 200],
[False, 404]
)
@ddt.unpack
def test_get_filters_retired(self, include_retired, status_code):
""" Verify that retired courses types do not appear by default """
bootcamp_type, _ = CourseType.objects.get_or_create(slug=CourseType.BOOTCAMP_2U)
bootcamp = CourseFactory(partner=self.partner, title='Fake Test', key='edX+bootcamp', type=bootcamp_type)
url = reverse('api:v1:course-detail', kwargs={'key': bootcamp.key})
if include_retired:
url += '?include_retired_course_types=1'

response = self.client.get(url)
assert response.status_code == status_code

def test_get_uuid(self):
""" Verify the endpoint returns the details for a single course with UUID. """
url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})

with self.assertNumQueries(27):
with self.assertNumQueries(28):
response = self.client.get(url)
assert response.status_code == 200
assert response.data == self.serialize_course(self.course)
Expand Down Expand Up @@ -354,6 +370,24 @@ def test_list(self):
self.serialize_course(Course.objects.all(), many=True)
)

@ddt.data(
[True, 2],
[False, 1]
)
@ddt.unpack
def test_list_filters_retired(self, include_retired, expected_length):
""" Verify that retired course types do not appear by default """
bootcamp_type, _ = CourseType.objects.get_or_create(slug=CourseType.BOOTCAMP_2U)
CourseFactory(partner=self.partner, title='Fake Test', key='edX+bootcamp', type=bootcamp_type)

url = reverse('api:v1:course-list')
if include_retired:
url += '?include_retired_course_types=1'

response = self.client.get(url)
assert response.status_code == 200
assert len(response.data['results']) == expected_length

def test_no_repeated_cache_calls_for_utm_calculation(self):
"""
Test that utm source calculation is done only once per request, and not per
Expand Down Expand Up @@ -507,7 +541,8 @@ def test_list_courses_course_type_filter(self, course_type, expected_length):
CourseFactory(partner=self.partner, title='Fake Test', key='edX+bootcamp', type=bootcamp_type)
CourseFactory(partner=self.partner, title='Fake Test', key='edX+ver', type=self.verified_type)

url = reverse('api:v1:course-list') + '?editable=1&course_type={}'.format(course_type)
query_params = '?editable=1&include_retired_course_types=1&course_type={}'.format(course_type)
url = reverse('api:v1:course-list') + query_params

response = self.client.get(url)
assert response.status_code == 200
Expand Down Expand Up @@ -2589,13 +2624,20 @@ def test_update_geolocation__validation_error(self):
assert GeoLocation.objects.count() == 1

@responses.activate
def test_options(self):
@ddt.data(
[['audit'], False],
[[], True]
)
@ddt.unpack
def test_options(self, retired_course_types, is_retired_type_in_result):
SubjectFactory(name='Subject1')
CourseEntitlementFactory(course=self.course, mode=SeatTypeFactory.verified())
audit_type, _ = CourseType.objects.get_or_create(slug=CourseType.AUDIT)

url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
with self.assertNumQueries(46, threshold=0):
response = self.client.options(url)
with override_settings(RETIRED_COURSE_TYPES=retired_course_types):
with self.assertNumQueries(46, threshold=1):
response = self.client.options(url)
assert response.status_code == 200

data = response.json()['actions']['PUT']
Expand All @@ -2620,6 +2662,9 @@ def test_options(self):
if options['uuid'] == str(credit_type.uuid):
credit_options = options
break
# Assert that retired course types do not appear in the result
type_uuids = [typ['uuid'] for typ in data['type']['type_options']]
assert (str(audit_type.uuid) in type_uuids) == is_retired_type_in_result
assert credit_options is not None
assert {t['mode']['slug'] for t in credit_options['tracks']} == {'verified', 'credit', 'audit'}

Expand Down
27 changes: 24 additions & 3 deletions course_discovery/apps/api/v1/tests/test_views/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
import json
import urllib.parse
import uuid
from operator import itemgetter

import ddt
import factory
import pytz
from django.core.management import call_command
from django.db.models import signals
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework.renderers import JSONRenderer

Expand Down Expand Up @@ -207,10 +209,10 @@ def test_availability_faceting(self):
['results', 0, 'program_types', 0], ProgramStatus.Unpublished, 5),
(detailed_path,
CourseRunSearchModelSerializer,
['results', 0, 'programs', 0, 'type'], ProgramStatus.Deleted, 21),
['results', 0, 'programs', 0, 'type'], ProgramStatus.Deleted, 22),
(detailed_path,
CourseRunSearchModelSerializer,
['results', 0, 'programs', 0, 'type'], ProgramStatus.Unpublished, 22),
['results', 0, 'programs', 0, 'type'], ProgramStatus.Unpublished, 23),
)
@ddt.unpack
def test_exclude_unavailable_program_types(self, path, serializer, result_location_keys, program_status,
Expand Down Expand Up @@ -597,7 +599,7 @@ def test_results_filtered_by_default_partner(self, short_code):
self.serialize_program_search(other_program),
]

@ddt.data((True, 9), (False, 9))
@ddt.data((True, 10), (False, 10))
@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. """
Expand Down Expand Up @@ -697,6 +699,25 @@ def test_results_filtered_by_exclude_expired_course_run(self, request_method):
expected_course_run_keys = [course_run.key for course_run in expected_course_runs]
assert set(actual_course_run_keys) == set(expected_course_run_keys)

@ddt.data("course", "courserun")
def test_retired_courses_and_runs_not_indexed(self, content_type):
""" Verify that courses and runs corresponding to retired types are not indexed. """

run1, run2, run3 = CourseRunFactory.create_batch(3, course__partner=self.partner)
with override_settings(RETIRED_RUN_TYPES=[run1.type.slug], RETIRED_COURSE_TYPES=[run1.course.type.slug]):
call_command('search_index', '--rebuild', '-f')

response = self.get_response(query={"content_type": content_type}, endpoint=self.list_path)
assert response.status_code == 200
response_data = response.json()
assert response_data['count'] == 2
if content_type == "course":
expected = [self.serialize_course_search(run2.course), self.serialize_course_search(run3.course)]
else:
expected = [self.serialize_course_run_search(run2), self.serialize_course_run_search(run3)]

assert sorted(response_data['results'], key=itemgetter('key')) == sorted(expected, key=itemgetter('key'))

def test_empty_query(self):
""" Verify, when the query (q) parameter is empty, the endpoint behaves as if the parameter
was not provided. """
Expand Down
8 changes: 7 additions & 1 deletion course_discovery/apps/api/v1/views/course_runs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

from django.conf import settings
from django.db import models, transaction
from django.db.models.functions import Lower
from django.http.response import Http404
Expand Down Expand Up @@ -27,7 +28,7 @@
from course_discovery.apps.course_metadata.choices import CourseRunStatus
from course_discovery.apps.course_metadata.constants import COURSE_RUN_ID_REGEX
from course_discovery.apps.course_metadata.exceptions import EcommerceSiteAPIClientException
from course_discovery.apps.course_metadata.models import Course, CourseEditor, CourseRun
from course_discovery.apps.course_metadata.models import Course, CourseEditor, CourseRun, CourseRunType
from course_discovery.apps.course_metadata.utils import ensure_draft_world
from course_discovery.apps.publisher.utils import is_publisher_user

Expand Down Expand Up @@ -110,6 +111,11 @@ def get_queryset(self):
)
else:
queryset = queryset.filter(course__partner=partner)
if self.request.method == "GET" and not get_query_param(self.request, 'include_retired_run_types'):
retired_type_ids = list(
CourseRunType.objects.filter(slug__in=settings.RETIRED_RUN_TYPES).values_list('id', flat=True)
)
queryset = queryset.exclude(type_id__in=retired_type_ids)

return self.get_serializer_class().prefetch_queryset(queryset=queryset)

Expand Down
Loading

0 comments on commit ef8782e

Please sign in to comment.