Commit 5f7ccf82 by Clinton Blackburn Committed by Clinton Blackburn

Added Publisher API endpoint to publish data to sources of truth

LEARNER-2472
parent 9d270564
......@@ -36,6 +36,10 @@ class UserFactory(factory.DjangoModelFactory):
model = User
class StaffUserFactory(UserFactory):
is_staff = True
class PartnerFactory(factory.DjangoModelFactory):
name = factory.Sequence(lambda n: 'test-partner-{}'.format(n)) # pylint: disable=unnecessary-lambda
short_code = factory.Sequence(lambda n: 'test{}'.format(n)) # pylint: disable=unnecessary-lambda
......
......@@ -291,6 +291,7 @@ class Course(TimeStampedModel):
)
slug = AutoSlugField(populate_from='key', editable=True)
video = models.ForeignKey(Video, default=None, null=True, blank=True)
# TODO Remove this field.
number = models.CharField(
max_length=50, null=True, blank=True, help_text=_(
'Course number format e.g CS002x, BIO1.1x, BIO1.2x'
......
""" Publisher API URLs. """
from django.conf.urls import url
from django.conf.urls import include, url
from course_discovery.apps.publisher.api.views import (AcceptAllRevisionView, ChangeCourseRunStateView,
ChangeCourseStateView, CourseRevisionDetailView,
......@@ -24,4 +24,5 @@ urlpatterns = [
r'^course/revision/(?P<history_id>\d+)/accept_revision/$',
AcceptAllRevisionView.as_view(), name='accept_all_revision'
),
url(r'^v1/', include('course_discovery.apps.publisher.api.v1.urls', namespace='v1')),
]
import mock
import responses
from django.urls import reverse
from rest_framework.test import APITestCase
from course_discovery.apps.core.models import Partner
from course_discovery.apps.core.tests.factories import StaffUserFactory, UserFactory
from course_discovery.apps.course_metadata.models import CourseRun, Video
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.tests.factories import CourseRunFactory
class CourseRunViewSet(APITestCase):
def setUp(self):
super().setUp()
self.client.force_login(StaffUserFactory())
def test_without_authentication(self):
self.client.logout()
url = reverse('publisher:api:v1:course_run-publish', kwargs={'pk': 1})
response = self.client.post(url, {})
assert response.status_code == 401
def test_without_authorization(self):
user = UserFactory()
self.client.force_login(user)
url = reverse('publisher:api:v1:course_run-publish', kwargs={'pk': 1})
response = self.client.post(url, {})
assert response.status_code == 403
@responses.activate
@mock.patch.object(Partner, 'access_token', return_value='JWT fake')
def test_publish(self, mock_access_token): # pylint: disable=unused-argument
organization = OrganizationFactory()
transcript_languages = [LanguageTag.objects.first()]
publisher_course_run = CourseRunFactory(
course__organizations=[organization],
course__tertiary_subject=None,
lms_course_id='a/b/c',
transcript_languages=transcript_languages
)
partner = organization.partner
# pylint:disable=attribute-defined-outside-init
self.client = self.client_class(SERVER_NAME=partner.site.domain)
self.client.force_login(StaffUserFactory())
body = {'id': publisher_course_run.lms_course_id}
url = '{root}/api/v1/course_runs/{key}/'.format(
root=partner.studio_url.strip('/'),
key=publisher_course_run.lms_course_id
)
responses.add(responses.PATCH, url, json=body, status=200)
url = '{root}/api/v1/course_runs/{key}/images/'.format(
root=partner.studio_url.strip('/'),
key=publisher_course_run.lms_course_id
)
responses.add(responses.POST, url, json=body, status=200)
url = '{root}publication/'.format(root=partner.ecommerce_api_url)
responses.add(responses.POST, url, json=body, status=200)
url = reverse('publisher:api:v1:course_run-publish', kwargs={'pk': publisher_course_run.pk})
response = self.client.post(url, {})
assert response.status_code == 200
assert len(responses.calls) == 3
discovery_course_run = CourseRun.objects.get(key=publisher_course_run.lms_course_id)
assert discovery_course_run.title_override == publisher_course_run.title_override
assert discovery_course_run.short_description_override is None
assert discovery_course_run.full_description_override is None
assert discovery_course_run.start == publisher_course_run.start
assert discovery_course_run.end == publisher_course_run.end
assert discovery_course_run.enrollment_start == publisher_course_run.enrollment_start
assert discovery_course_run.enrollment_end == publisher_course_run.enrollment_end
assert discovery_course_run.pacing_type == publisher_course_run.pacing_type
assert discovery_course_run.min_effort == publisher_course_run.min_effort
assert discovery_course_run.max_effort == publisher_course_run.max_effort
assert discovery_course_run.language == publisher_course_run.language
assert set(discovery_course_run.transcript_languages.all()) == set(transcript_languages)
publisher_course = publisher_course_run.course
discovery_course = discovery_course_run.course
assert discovery_course.canonical_course_run == discovery_course_run
assert discovery_course.partner == partner
assert discovery_course.title == publisher_course.title
assert discovery_course.short_description == publisher_course.short_description
assert discovery_course.full_description == publisher_course.full_description
assert discovery_course.level_type == publisher_course.level_type
assert discovery_course.video == Video.objects.get(src=publisher_course.video_link)
assert list(discovery_course.authoring_organizations.all()) == [organization]
assert set(discovery_course.subjects.all()) == {publisher_course.primary_subject,
publisher_course.secondary_subject}
def test_publish_missing_course_run(self):
url = reverse('publisher:api:v1:course_run-publish', kwargs={'pk': 1})
response = self.client.post(url, {})
assert response.status_code == 404
from rest_framework.routers import DefaultRouter
from .views import CourseRunViewSet
router = DefaultRouter()
router.register(r'course_runs', CourseRunViewSet, base_name='course_run')
urlpatterns = router.urls
import logging
from edx_rest_api_client.client import EdxRestApiClient
from edx_rest_framework_extensions.authentication import JwtAuthentication
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from slumber.exceptions import SlumberBaseException
from course_discovery.apps.core.utils import serialize_datetime
from course_discovery.apps.course_metadata.models import CourseRun as DiscoveryCourseRun
from course_discovery.apps.course_metadata.models import Course, Video
from course_discovery.apps.publisher.models import CourseRun, Seat
from course_discovery.apps.publisher.studio_api_utils import StudioAPI
logger = logging.getLogger(__name__)
class CourseRunViewSet(viewsets.GenericViewSet):
authentication_classes = (JwtAuthentication, SessionAuthentication,)
lookup_url_kwarg = 'pk'
queryset = CourseRun.objects.all()
# NOTE: We intentionally use a basic serializer here since there is nothing, yet, to return.
serializer_class = serializers.Serializer
permission_classes = (permissions.IsAdminUser,)
@detail_route(methods=['post'])
def publish(self, request, pk=None):
course_run = self.get_object()
partner = request.site.partner
try:
self.publish_to_studio(partner, course_run)
self.publish_to_ecommerce(partner, course_run)
self.publish_to_discovery(partner, course_run)
except SlumberBaseException as ex:
logger.exception('Failed to publish course run [%s]!', pk)
content = getattr(ex, 'content', None)
if content:
logger.error(content)
raise
return Response({}, status=status.HTTP_200_OK)
def publish_to_studio(self, partner, course_run):
api = StudioAPI(partner.studio_api_client)
api.update_course_run_details_in_studio(course_run)
api.update_course_run_image_in_studio(course_run)
def publish_to_ecommerce(self, partner, course_run):
api = EdxRestApiClient(partner.ecommerce_api_url, jwt=partner.access_token)
data = {
'id': course_run.lms_course_id,
'name': course_run.title_override or course_run.course.title,
'verification_deadline': None,
'create_or_activate_enrollment_code': False,
'products': [
{
'expires': serialize_datetime(seat.upgrade_deadline),
'price': str(seat.price),
'product_class': 'Seat',
'attribute_values': [
{
'name': 'certificate_type',
'value': None if seat.type is Seat.AUDIT else seat.type,
},
{
'name': 'id_verification_required',
'value': seat.type in (Seat.VERIFIED, Seat.PROFESSIONAL),
}
]
} for seat in course_run.seats.all()
]
}
api.publication.post(data)
def publish_to_discovery(self, partner, course_run):
publisher_course = course_run.course
course_key = '{org}+{number}'.format(org=publisher_course.organizations.first().key,
number=publisher_course.number)
video = None
if publisher_course.video_link:
video, __ = Video.objects.get_or_create(src=publisher_course.video_link)
# TODO Host card images from the Discovery Service CDN
defaults = {
'title': publisher_course.title,
'short_description': publisher_course.short_description,
'full_description': publisher_course.full_description,
'level_type': publisher_course.level_type,
'video': video,
}
discovery_course, created = Course.objects.update_or_create(partner=partner, key=course_key, defaults=defaults)
discovery_course.authoring_organizations.add(*publisher_course.organizations.all())
subjects = [subject for subject in set([
publisher_course.primary_subject,
publisher_course.secondary_subject,
publisher_course.tertiary_subject
]) if subject]
discovery_course.subjects.add(*subjects)
defaults = {
'start': course_run.start,
'end': course_run.end,
'enrollment_start': course_run.enrollment_start,
'enrollment_end': course_run.enrollment_end,
'pacing_type': course_run.pacing_type,
'title_override': course_run.title_override,
'min_effort': course_run.min_effort,
'max_effort': course_run.max_effort,
'language': course_run.language,
}
discovery_course_run, __ = DiscoveryCourseRun.objects.update_or_create(
course=discovery_course,
key=course_run.lms_course_id,
defaults=defaults
)
discovery_course_run.transcript_languages.add(*course_run.transcript_languages.all())
if created:
discovery_course.canonical_course_run = discovery_course_run
discovery_course.save()
......@@ -79,3 +79,8 @@ class StudioAPI:
def update_course_run_image_in_studio(self, publisher_course_run):
files = {'card_image': publisher_course_run.course.image}
return self._api.course_runs(publisher_course_run.lms_course_id).images.post(files=files)
def update_course_run_details_in_studio(self, publisher_course_run):
data = self.generate_data_for_studio_api(publisher_course_run)
# NOTE: We use PATCH to avoid overwriting existing team data that may have been manually input in Studio.
return self._api.course_runs(publisher_course_run.lms_course_id).patch(data)
......@@ -65,6 +65,11 @@ class CourseRunFactory(factory.DjangoModelFactory):
title_override = FuzzyText()
full_description_override = FuzzyText()
@factory.post_generation
def transcript_languages(self, create, extracted, **kwargs): # pylint: disable=unused-argument
if create:
add_m2m_data(self.transcript_languages, extracted)
class Meta:
model = CourseRun
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment