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): ...@@ -36,6 +36,10 @@ class UserFactory(factory.DjangoModelFactory):
model = User model = User
class StaffUserFactory(UserFactory):
is_staff = True
class PartnerFactory(factory.DjangoModelFactory): class PartnerFactory(factory.DjangoModelFactory):
name = factory.Sequence(lambda n: 'test-partner-{}'.format(n)) # pylint: disable=unnecessary-lambda 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 short_code = factory.Sequence(lambda n: 'test{}'.format(n)) # pylint: disable=unnecessary-lambda
......
...@@ -291,6 +291,7 @@ class Course(TimeStampedModel): ...@@ -291,6 +291,7 @@ class Course(TimeStampedModel):
) )
slug = AutoSlugField(populate_from='key', editable=True) slug = AutoSlugField(populate_from='key', editable=True)
video = models.ForeignKey(Video, default=None, null=True, blank=True) video = models.ForeignKey(Video, default=None, null=True, blank=True)
# TODO Remove this field.
number = models.CharField( number = models.CharField(
max_length=50, null=True, blank=True, help_text=_( max_length=50, null=True, blank=True, help_text=_(
'Course number format e.g CS002x, BIO1.1x, BIO1.2x' 'Course number format e.g CS002x, BIO1.1x, BIO1.2x'
......
""" Publisher API URLs. """ """ 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, from course_discovery.apps.publisher.api.views import (AcceptAllRevisionView, ChangeCourseRunStateView,
ChangeCourseStateView, CourseRevisionDetailView, ChangeCourseStateView, CourseRevisionDetailView,
...@@ -24,4 +24,5 @@ urlpatterns = [ ...@@ -24,4 +24,5 @@ urlpatterns = [
r'^course/revision/(?P<history_id>\d+)/accept_revision/$', r'^course/revision/(?P<history_id>\d+)/accept_revision/$',
AcceptAllRevisionView.as_view(), name='accept_all_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: ...@@ -79,3 +79,8 @@ class StudioAPI:
def update_course_run_image_in_studio(self, publisher_course_run): def update_course_run_image_in_studio(self, publisher_course_run):
files = {'card_image': publisher_course_run.course.image} files = {'card_image': publisher_course_run.course.image}
return self._api.course_runs(publisher_course_run.lms_course_id).images.post(files=files) 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): ...@@ -65,6 +65,11 @@ class CourseRunFactory(factory.DjangoModelFactory):
title_override = FuzzyText() title_override = FuzzyText()
full_description_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: class Meta:
model = CourseRun 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