Commit 1efd2cbd by Jeff LaJoie Committed by Jeff LaJoie

LEARNER-4477: Fixes enrollment-track cascading changes at course level

parent 637dc603
...@@ -2996,13 +2996,14 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -2996,13 +2996,14 @@ class CourseEditViewTests(SiteMixin, TestCase):
course=self.course, lms_course_id='course-v1:edxTest+Test342+2016Q1', end=datetime.now() + timedelta(days=1) course=self.course, lms_course_id='course-v1:edxTest+Test342+2016Q1', end=datetime.now() + timedelta(days=1)
) )
factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published) factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Draft)
factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.Publisher) factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.Publisher)
factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator) factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator)
# Test that this saves without seats after resetting this to Seat version # Test that this saves without seats after resetting this to Seat version
self.course.version = Course.SEAT_VERSION self.course.version = Course.SEAT_VERSION
self.course.save() self.course.save()
post_data['mode'] = CourseEntitlementForm.PROFESSIONAL_MODE post_data['mode'] = CourseEntitlementForm.PROFESSIONAL_MODE
post_data['price'] = 1 post_data['price'] = 1
response = self.client.post(self.edit_page_url, data=post_data) response = self.client.post(self.edit_page_url, data=post_data)
...@@ -3013,11 +3014,12 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -3013,11 +3014,12 @@ class CourseEditViewTests(SiteMixin, TestCase):
target_status_code=200 target_status_code=200
) )
verified_seat = factories.SeatFactory.create(course_run=course_run, type=Seat.VERIFIED, price=2) # Clear out the seats created above and reset the version to test the mismatch cases
factories.SeatFactory(course_run=course_run, type=Seat.AUDIT, price=0) # Create a seat, do not need to access course_run.seats.all().delete()
self.course.version = Course.SEAT_VERSION self.course.version = Course.SEAT_VERSION
self.course.save() self.course.save()
verified_seat = factories.SeatFactory.create(course_run=course_run, type=Seat.VERIFIED, price=2)
factories.SeatFactory(course_run=course_run, type=Seat.AUDIT, price=0) # Create a seat, do not need to access
# Verify that we can switch between NOOP_MODES # Verify that we can switch between NOOP_MODES
for noop_mode in [''] + CourseEntitlementForm.NOOP_MODES: for noop_mode in [''] + CourseEntitlementForm.NOOP_MODES:
...@@ -3048,7 +3050,9 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -3048,7 +3050,9 @@ class CourseEditViewTests(SiteMixin, TestCase):
# Modify the Course to try and create CourseEntitlement the same as the Course Run and Seat type and price # Modify the Course to try and create CourseEntitlement the same as the Course Run and Seat type and price
post_data['mode'] = verified_seat.type post_data['mode'] = verified_seat.type
post_data['price'] = verified_seat.price post_data['price'] = verified_seat.price
response = self.client.post(self.edit_page_url, data=post_data) response = self.client.post(self.edit_page_url, data=post_data)
self.assertRedirects( self.assertRedirects(
response, response,
expected_url=reverse('publisher:publisher_course_detail', kwargs={'pk': self.course.id}), expected_url=reverse('publisher:publisher_course_detail', kwargs={'pk': self.course.id}),
...@@ -3132,23 +3136,22 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -3132,23 +3136,22 @@ class CourseEditViewTests(SiteMixin, TestCase):
response = self.client.post(new_run_url, run_data) response = self.client.post(new_run_url, run_data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.course.refresh_from_db()
course_run = self.course.course_runs.first()
# Sanity check that we have the expected entitlement & seats # Sanity check that we have the expected entitlement & seats
self.assertEqual(CourseEntitlement.objects.count(), 1) self.assertEqual(CourseEntitlement.objects.count(), 1)
entitlement = CourseEntitlement.objects.get(mode=CourseEntitlement.VERIFIED, price=150)
self.assertEqual(Seat.objects.count(), 2) self.assertEqual(Seat.objects.count(), 2)
paid_seat = Seat.objects.get(type=Seat.VERIFIED, price=150)
audit_seat = Seat.objects.get(type=Seat.AUDIT, price=0)
def refresh_from_db():
entitlement.refresh_from_db()
paid_seat.refresh_from_db()
audit_seat.refresh_from_db()
# Test price change # Test price change
course_data['mode'] = CourseEntitlement.VERIFIED
course_data['price'] = 99 course_data['price'] = 99
response = self.client.post(self.edit_page_url, data=course_data) response = self.client.post(self.edit_page_url, data=course_data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
refresh_from_db() # Use this query because we delete the original records
entitlement = CourseEntitlement.objects.get(course=self.course) # Should be only one entitlement for the Course
paid_seat = Seat.objects.get(type=Seat.VERIFIED, course_run=course_run)
audit_seat = Seat.objects.get(type=Seat.AUDIT, course_run=course_run)
self.assertEqual(entitlement.price, 99) self.assertEqual(entitlement.price, 99)
self.assertEqual(paid_seat.price, 99) self.assertEqual(paid_seat.price, 99)
self.assertEqual(audit_seat.price, 0) self.assertEqual(audit_seat.price, 0)
...@@ -3157,10 +3160,54 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -3157,10 +3160,54 @@ class CourseEditViewTests(SiteMixin, TestCase):
course_data['mode'] = CourseEntitlement.PROFESSIONAL course_data['mode'] = CourseEntitlement.PROFESSIONAL
response = self.client.post(self.edit_page_url, data=course_data) response = self.client.post(self.edit_page_url, data=course_data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
refresh_from_db() paid_seat = Seat.objects.get(course_run=course_run) # Should be only one seat now (no Audit seat)
entitlement = CourseEntitlement.objects.get(course=self.course) # Should be only one entitlement for the Course
self.assertEqual(entitlement.mode, CourseEntitlement.PROFESSIONAL) self.assertEqual(entitlement.mode, CourseEntitlement.PROFESSIONAL)
self.assertEqual(paid_seat.type, Seat.PROFESSIONAL) self.assertEqual(paid_seat.type, Seat.PROFESSIONAL)
self.assertEqual(audit_seat.type, Seat.AUDIT)
# Test mode and price change again after saving but BEFORE publishing
course_data['mode'] = CourseEntitlement.VERIFIED
course_data['price'] = 1000
response = self.client.post(self.edit_page_url, data=course_data)
self.assertEqual(response.status_code, 302)
# There should be both an audit seat and a verified seat
audit_seat = Seat.objects.get(course_run=course_run, type=Seat.AUDIT)
verified_seat = Seat.objects.get(course_run=course_run, type=Seat.VERIFIED)
entitlement = CourseEntitlement.objects.get(course=self.course) # Should be only one entitlement for the Course
self.assertNotEqual(audit_seat, None)
self.assertEqual(entitlement.mode, CourseEntitlement.VERIFIED)
self.assertEqual(entitlement.price, 1000)
self.assertEqual(verified_seat.type, Seat.VERIFIED)
self.assertEqual(verified_seat.price, 1000)
def test_entitlement_published_run_failure(self):
"""
Verify that a course with a published course run cannot be saved with altered enrollment-track or price fields.
"""
self.client.logout()
self.client.login(username=self.course_team_role.user.username, password=USER_PASSWORD)
self._assign_permissions(self.organization_extension)
self.course.version = Course.ENTITLEMENT_VERSION
self.course.save()
factories.CourseEntitlementFactory(course=self.course, mode=CourseEntitlement.VERIFIED)
course_run = factories.CourseRunFactory.create(
course=self.course, lms_course_id='course-v1:edxTest+Test342+2016Q1', end=datetime.now() + timedelta(days=1)
)
factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published)
factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.Publisher)
factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator)
# Create a Verified and Audit seat
factories.SeatFactory.create(course_run=course_run, type=Seat.VERIFIED, price=100)
factories.SeatFactory(course_run=course_run, type=Seat.AUDIT, price=0)
post_data = self._post_data(self.organization_extension)
post_data['team_admin'] = self.course_team_role.user.id
post_data['mode'] = CourseEntitlement.PROFESSIONAL
post_data['price'] = 100
response = self.client.post(self.edit_page_url, data=post_data)
self.assertEqual(response.status_code, 400)
def test_course_with_published_course_run(self): def test_course_with_published_course_run(self):
""" """
......
...@@ -427,6 +427,16 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -427,6 +427,16 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
def _get_active_course_runs(self, course): def _get_active_course_runs(self, course):
return course.course_runs.filter(end__gt=datetime.now()) return course.course_runs.filter(end__gt=datetime.now())
def _get_published_course_runs(self, course):
published_runs = set()
for course_run in self._get_active_course_runs(course):
if course_run.course_run_state.is_published:
published_runs.add('{type} - {start}'.format(
type=course_run.get_pacing_type_display(),
start=course_run.start.strftime("%B %d, %Y")
))
return published_runs
def _get_misconfigured_course_runs(self, course, price, mode): def _get_misconfigured_course_runs(self, course, price, mode):
misconfigured_seat_type_runs = set() misconfigured_seat_type_runs = set()
misconfigured_price_runs = set() misconfigured_price_runs = set()
...@@ -457,6 +467,7 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -457,6 +467,7 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
type=course_run.get_pacing_type_display(), type=course_run.get_pacing_type_display(),
start=course_run.start.strftime("%B %d, %Y") start=course_run.start.strftime("%B %d, %Y")
)) ))
return misconfigured_price_runs, misconfigured_seat_type_runs return misconfigured_price_runs, misconfigured_seat_type_runs
def _create_or_update_course_entitlement(self, course, entitlement_form): def _create_or_update_course_entitlement(self, course, entitlement_form):
...@@ -470,15 +481,18 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -470,15 +481,18 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
return entitlement return entitlement
@transaction.atomic @transaction.atomic
def _update_seats_from_entitlement(self, course, entitlement): def _update_seats_from_entitlement(self, course, entitlement, changed_by):
for run in self._get_active_course_runs(course): for run in self._get_active_course_runs(course):
for seat in run.seats.all(): run.seats.all().delete()
# First make sure this seat is on the "entitlement side of things" (i.e. not an audit seat or similar) seat = Seat(
if seat.type in CourseEntitlement.MODE_TO_SEAT_TYPE_MAPPING.values(): type=CourseEntitlement.MODE_TO_SEAT_TYPE_MAPPING[entitlement.mode],
seat.type = CourseEntitlement.MODE_TO_SEAT_TYPE_MAPPING[entitlement.mode] price=entitlement.price,
seat.price = entitlement.price currency=entitlement.currency
seat.currency = entitlement.currency )
seat.save()
# Use the SeatForm here to not duplicate logic for creating seats
seat_form = SeatForm(instance=seat)
seat_form.save(commit=True, course_run=run, changed_by=changed_by)
def _render_post_error(self, request, ctx_overrides=None, status=400): def _render_post_error(self, request, ctx_overrides=None, status=400):
context = self.get_context_data() context = self.get_context_data()
...@@ -495,7 +509,7 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -495,7 +509,7 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
if course.uses_entitlements: if course.uses_entitlements:
entitlement = self._create_or_update_course_entitlement(course, entitlement_form) entitlement = self._create_or_update_course_entitlement(course, entitlement_form)
self._update_seats_from_entitlement(course, entitlement) self._update_seats_from_entitlement(course, entitlement, user)
return course return course
...@@ -548,7 +562,8 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -548,7 +562,8 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
'course_form': course_form, 'course_form': course_form,
'entitlement_form': entitlement_form 'entitlement_form': entitlement_form
}) })
elif self.object.uses_entitlements and not entitlement_mode: elif self.object.uses_entitlements:
if not entitlement_mode:
messages.error(request, _( messages.error(request, _(
"Enrollment track cannot be unset or changed from verified or professional to audit or credit." "Enrollment track cannot be unset or changed from verified or professional to audit or credit."
)) ))
...@@ -556,6 +571,20 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView): ...@@ -556,6 +571,20 @@ class CourseEditView(mixins.PublisherPermissionMixin, UpdateView):
'course_form': course_form, 'course_form': course_form,
'entitlement_form': entitlement_form 'entitlement_form': entitlement_form
}) })
published_runs = self._get_published_course_runs(self.object)
if published_runs:
# pylint: disable=no-member
error_message = _(
'The following active course run(s) are published: {course_runs}. You cannot change the mode '
'if there are published active runs.'
).format(course_runs=', '.join(
str(course_run_start) for course_run_start in published_runs
))
messages.error(request, error_message)
return self._render_post_error(request, ctx_overrides={
'course_form': course_form,
'entitlement_form': entitlement_form
})
version = Course.ENTITLEMENT_VERSION if entitlement_mode else Course.SEAT_VERSION version = Course.ENTITLEMENT_VERSION if entitlement_mode else Course.SEAT_VERSION
self._update_course(course_form, entitlement_form, user, version) self._update_course(course_form, entitlement_form, user, version)
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-03-08 09:59+0000\n" "POT-Creation-Date: 2018-03-15 20:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -3336,6 +3336,13 @@ msgid "" ...@@ -3336,6 +3336,13 @@ msgid ""
msgstr "" msgstr ""
#: apps/publisher/views.py #: apps/publisher/views.py
#, python-brace-format
msgid ""
"The following active course run(s) are published: {course_runs}. You cannot "
"change the mode if there are published active runs."
msgstr ""
#: apps/publisher/views.py
msgid "Course updated successfully." msgid "Course updated successfully."
msgstr "" msgstr ""
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-03-08 09:59+0000\n" "POT-Creation-Date: 2018-03-15 20:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-03-08 09:59+0000\n" "POT-Creation-Date: 2018-03-15 20:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -4071,6 +4071,15 @@ msgstr "" ...@@ -4071,6 +4071,15 @@ msgstr ""
" äüdït ör çrédït. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" " äüdït ör çrédït. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
#: apps/publisher/views.py #: apps/publisher/views.py
#, python-brace-format
msgid ""
"The following active course run(s) are published: {course_runs}. You cannot "
"change the mode if there are published active runs."
msgstr ""
"Thé föllöwïng äçtïvé çöürsé rün(s) äré püßlïshéd: {course_runs}. Ýöü çännöt "
"çhängé thé mödé ïf théré äré püßlïshéd äçtïvé rüns. Ⱡ'σяєм ιρѕυм ∂#"
#: apps/publisher/views.py
msgid "Course updated successfully." msgid "Course updated successfully."
msgstr "Çöürsé üpdätéd süççéssfüllý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" msgstr "Çöürsé üpdätéd süççéssfüllý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-03-08 09:59+0000\n" "POT-Creation-Date: 2018-03-15 20:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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