Commit 7f2cad81 by Waheed Ahmed

Added CourseState change endpoint.

ECOM-7004
parent 69483e86
......@@ -2,7 +2,7 @@ from rest_framework.permissions import BasePermission
from course_discovery.apps.publisher.mixins import check_roles_access, check_course_organization_permission
from course_discovery.apps.publisher.models import OrganizationExtension
from course_discovery.apps.publisher.utils import is_internal_user
from course_discovery.apps.publisher.utils import is_internal_user, is_publisher_user
class CanViewAssociatedCourse(BasePermission):
......@@ -23,3 +23,10 @@ class InternalUserPermission(BasePermission):
def has_object_permission(self, request, view, obj):
return is_internal_user(request.user)
class PublisherUserPermission(BasePermission):
""" Permission class to check user is a publisher user. """
def has_object_permission(self, request, view, obj):
return is_publisher_user(request.user)
......@@ -3,13 +3,14 @@ import waffle
from django.apps import apps
from django.utils.translation import ugettext_lazy as _
from django_fsm import TransitionNotAllowed
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import serializers
from course_discovery.apps.core.models import User
from course_discovery.apps.publisher.emails import send_email_for_studio_instance_created
from course_discovery.apps.publisher.models import CourseUserRole, CourseRun
from course_discovery.apps.publisher.models import CourseUserRole, CourseRun, CourseState
class CourseUserRoleSerializer(serializers.ModelSerializer):
......@@ -98,3 +99,32 @@ class CourseRevisionSerializer(serializers.ModelSerializer):
def get_tertiary_subject(self, obj):
if obj.tertiary_subject:
return obj.tertiary_subject.name
class CourseStateSerializer(serializers.ModelSerializer):
"""Serializer for `CourseState` model to change course workflow state. """
class Meta:
model = CourseState
fields = ('name', 'approved_by_role', 'owner_role', 'course',)
extra_kwargs = {
'course': {'read_only': True},
'approved_by_role': {'read_only': True},
'owner_role': {'read_only': True}
}
def update(self, instance, validated_data):
state = validated_data.get('name')
try:
instance.change_state(state=state)
except TransitionNotAllowed:
# pylint: disable=no-member
raise serializers.ValidationError(
{
'name': _('Cannot switch from state `{state}` to `{target_state}`').format(
state=instance.name, target_state=state
)
}
)
return instance
......@@ -4,9 +4,13 @@ from rest_framework.exceptions import ValidationError
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.publisher.api.serializers import (
CourseUserRoleSerializer, GroupUserSerializer, UpdateCourseKeySerializer, CourseRevisionSerializer
CourseUserRoleSerializer, GroupUserSerializer, UpdateCourseKeySerializer, CourseRevisionSerializer,
CourseStateSerializer
)
from course_discovery.apps.publisher.tests.factories import CourseUserRoleFactory, CourseRunFactory, CourseFactory
from course_discovery.apps.publisher.tests.factories import CourseFactory
from course_discovery.apps.publisher.choices import CourseStateChoices
from course_discovery.apps.publisher.models import CourseState
from course_discovery.apps.publisher.tests.factories import CourseUserRoleFactory, CourseRunFactory, CourseStateFactory
class CourseUserRoleSerializerTests(TestCase):
......@@ -123,3 +127,33 @@ class CourseRevisionSerializerTests(TestCase):
}
self.assertDictEqual(serializer.data, expected)
class CourseStateSerializerTests(TestCase):
serializer_class = CourseStateSerializer
def setUp(self):
super(CourseStateSerializerTests, self).setUp()
self.course_state = CourseStateFactory(name=CourseStateChoices.Draft)
def test_update(self):
"""
Verify that we can update course workflow state with serializer.
"""
self.assertNotEqual(self.course_state, CourseStateChoices.Review)
serializer = self.serializer_class(self.course_state)
data = {'name': CourseStateChoices.Review}
serializer.update(self.course_state, data)
self.course_state = CourseState.objects.get(course=self.course_state.course)
self.assertEqual(self.course_state.name, CourseStateChoices.Review)
def test_update_with_error(self):
"""
Verify that serializer raises `ValidationError` with wrong transition.
"""
serializer = self.serializer_class(self.course_state)
data = {'name': CourseStateChoices.Approved}
with self.assertRaises(ValidationError):
serializer.update(self.course_state, data)
......@@ -14,9 +14,9 @@ from guardian.shortcuts import assign_perm
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.course_metadata.tests import toggle_switch
from course_discovery.apps.publisher.choices import PublisherUserRole
from course_discovery.apps.publisher.choices import PublisherUserRole, CourseStateChoices
from course_discovery.apps.publisher.constants import INTERNAL_USER_GROUP_NAME
from course_discovery.apps.publisher.models import CourseRun, OrganizationExtension
from course_discovery.apps.publisher.models import CourseRun, OrganizationExtension, CourseState
from course_discovery.apps.publisher.tests import factories, JSON_CONTENT_TYPE
......@@ -373,3 +373,54 @@ class CourseRevisionDetailViewTests(TestCase):
'publisher:api:course_revisions', kwargs={'history_id': revision_id}
)
return self.client.get(path=course_revision_path)
class ChangeCourseStateViewTests(TestCase):
def setUp(self):
super(ChangeCourseStateViewTests, self).setUp()
self.course_state = factories.CourseStateFactory(name=CourseStateChoices.Draft)
self.user = UserFactory()
self.user.groups.add(Group.objects.get(name=INTERNAL_USER_GROUP_NAME))
self.change_state_url = reverse('publisher:api:change_course_state', kwargs={'pk': self.course_state.id})
self.client.login(username=self.user.username, password=USER_PASSWORD)
def test_change_course_state(self):
"""
Verify that publisher user can change course workflow state.
"""
self.assertNotEqual(self.course_state.name, CourseStateChoices.Review)
response = self.client.patch(
self.change_state_url,
data=json.dumps({'name': CourseStateChoices.Review}),
content_type=JSON_CONTENT_TYPE
)
self.assertEqual(response.status_code, 200)
self.course_state = CourseState.objects.get(course=self.course_state.course)
self.assertEqual(self.course_state.name, CourseStateChoices.Review)
def test_change_course_state_with_error(self):
"""
Verify that user cannot change course workflow state directly from `Draft` to `Approved`.
"""
response = self.client.patch(
self.change_state_url,
data=json.dumps({'name': CourseStateChoices.Approved}),
content_type=JSON_CONTENT_TYPE
)
self.assertEqual(response.status_code, 400)
expected = {
'name': 'Cannot switch from state `{state}` to `{target_state}`'.format(
state=self.course_state.name, target_state=CourseStateChoices.Approved
)
}
self.assertEqual(response.data, expected)
......@@ -2,13 +2,15 @@
from django.conf.urls import url
from course_discovery.apps.publisher.api.views import (
CourseRoleAssignmentView, OrganizationGroupUserView, UpdateCourseKeyView, CourseRevisionDetailView
CourseRoleAssignmentView, OrganizationGroupUserView, UpdateCourseKeyView, CourseRevisionDetailView,
ChangeCourseStateView
)
urlpatterns = [
url(r'^course_role_assignments/(?P<pk>\d+)/$', CourseRoleAssignmentView.as_view(), name='course_role_assignments'),
url(r'^admins/organizations/(?P<pk>\d+)/users/$', OrganizationGroupUserView.as_view(),
name='organization_group_users'),
url(r'^course_state/(?P<pk>\d+)/$', ChangeCourseStateView.as_view(), name='change_course_state'),
url(r'^course_runs/(?P<pk>\d+)/$', UpdateCourseKeyView.as_view(), name='update_course_key'),
url(r'^course_revisions/(?P<history_id>\d+)/$', CourseRevisionDetailView.as_view(), name='course_revisions'),
]
......@@ -2,11 +2,14 @@ from rest_framework.generics import UpdateAPIView, ListAPIView, get_object_or_40
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.core.models import User
from course_discovery.apps.publisher.models import CourseUserRole, OrganizationExtension, CourseRun, Course
from course_discovery.apps.publisher.api.permissions import CanViewAssociatedCourse, InternalUserPermission
from course_discovery.apps.publisher.api.permissions import (
CanViewAssociatedCourse, InternalUserPermission, PublisherUserPermission
)
from course_discovery.apps.publisher.api.serializers import (
CourseUserRoleSerializer, GroupUserSerializer, UpdateCourseKeySerializer, CourseRevisionSerializer
CourseUserRoleSerializer, GroupUserSerializer, UpdateCourseKeySerializer, CourseRevisionSerializer,
CourseStateSerializer
)
from course_discovery.apps.publisher.models import CourseUserRole, OrganizationExtension, CourseRun, CourseState, Course
class CourseRoleAssignmentView(UpdateAPIView):
......@@ -36,3 +39,9 @@ class CourseRevisionDetailView(RetrieveAPIView):
serializer_class = CourseRevisionSerializer
queryset = Course.history.all() # pylint: disable=no-member
lookup_field = 'history_id'
class ChangeCourseStateView(UpdateAPIView):
permission_classes = (IsAuthenticated, PublisherUserPermission,)
queryset = CourseState.objects.all()
serializer_class = CourseStateSerializer
......@@ -509,6 +509,16 @@ class CourseState(TimeStampedModel, ChangedByMixin):
# TODO: send email etc.
pass
def change_state(self, state):
if state == CourseStateChoices.Draft:
self.draft()
elif state == CourseStateChoices.Review:
self.review()
elif state == CourseStateChoices.Approved:
self.approved()
self.save()
class CourseRunState(TimeStampedModel, ChangedByMixin):
""" Publisher Workflow Course Run State Model. """
......
......@@ -408,12 +408,14 @@ class GroupOrganizationTests(TestCase):
)
@ddt.ddt
class CourseStateTests(TestCase):
""" Tests for the publisher `CourseState` model. """
def setUp(self):
super(CourseStateTests, self).setUp()
self.course_state = factories.CourseStateFactory(name=CourseStateChoices.Draft)
@classmethod
def setUpClass(cls):
super(CourseStateTests, cls).setUpClass()
cls.course_state = factories.CourseStateFactory(name=CourseStateChoices.Draft)
def test_str(self):
"""
......@@ -421,30 +423,20 @@ class CourseStateTests(TestCase):
"""
self.assertEqual(str(self.course_state), self.course_state.get_name_display())
def test_draft(self):
self.course_state.review()
self.assertNotEqual(self.course_state.name, CourseStateChoices.Draft)
self.course_state.draft()
self.assertEqual(self.course_state.name, CourseStateChoices.Draft)
def test_review(self):
self.assertNotEqual(self.course_state.name, CourseStateChoices.Review)
self.course_state.review()
self.assertEqual(self.course_state.name, CourseStateChoices.Review)
def test_approved(self):
self.course_state.review()
self.assertNotEqual(self.course_state.name, CourseStateChoices.Approved)
@ddt.data(
CourseStateChoices.Review,
CourseStateChoices.Approved,
CourseStateChoices.Draft
)
def test_change_state(self, state):
"""
Verify that we can change course state according to workflow.
"""
self.assertNotEqual(self.course_state.name, state)
self.course_state.approved()
self.course_state.change_state(state=state)
self.assertEqual(self.course_state.name, CourseStateChoices.Approved)
self.assertEqual(self.course_state.name, state)
class CourseRunStateTests(TestCase):
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-02-03 12:49+0500\n"
"POT-Creation-Date: 2017-02-03 13:07+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -437,6 +437,11 @@ msgstr ""
msgid "Invalid course key \"{lms_course_id}\""
msgstr ""
#: apps/publisher/api/serializers.py
#, python-brace-format
msgid "Cannot switch from state `{state}` to `{target_state}`"
msgstr ""
#: apps/publisher/choices.py
msgid "Partner Manager"
msgstr ""
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-02-03 12:49+0500\n"
"POT-Creation-Date: 2017-02-03 13:07+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-02-03 12:49+0500\n"
"POT-Creation-Date: 2017-02-03 13:07+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -549,6 +549,13 @@ msgstr ""
msgid "Invalid course key \"{lms_course_id}\""
msgstr "Ìnvälïd çöürsé kéý \"{lms_course_id}\" Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#"
#: apps/publisher/api/serializers.py
#, python-brace-format
msgid "Cannot switch from state `{state}` to `{target_state}`"
msgstr ""
"Çännöt swïtçh fröm stäté `{state}` tö `{target_state}` Ⱡ'σяєм ιρѕυм ∂σłσя "
"ѕιт αмєт, ¢σηѕє¢тєтυя#"
#: apps/publisher/choices.py
msgid "Partner Manager"
msgstr "Pärtnér Mänägér Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-02-03 12:49+0500\n"
"POT-Creation-Date: 2017-02-03 13:07+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......
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