Commit c5d0f4da by Anthony Mangano

Create entitlement-aware CourseRuns in Publisher

parent 8d83ddfd
......@@ -920,6 +920,26 @@ class CoursesAutoCompleteTests(SiteMixin, TestCase):
response = self.client.get(self.course_autocomplete_url.format(title='test'))
self._assert_response(response, 1)
def test_course_autocomplete_entitlement_info(self):
""" Verify that the response from CourseAutoComplete includes info about whether or not the courses
use entitlements. """
self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME))
self.course.version = Course.SEAT_VERSION
self.course.save()
self.course2.version = Course.ENTITLEMENT_VERSION
self.course2.save()
response = self.client.get(self.course_autocomplete_url.format(title='test'))
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertEqual(len(data['results']), 2)
results_by_id = {record['id']: record for record in data['results']}
self.assertFalse(results_by_id[self.course.id]['uses_entitlements'])
self.assertTrue(results_by_id[self.course2.id]['uses_entitlements'])
def _assert_response(self, response, expected_length):
""" Assert autocomplete response. """
self.assertEqual(response.status_code, 200)
......
......@@ -100,6 +100,21 @@ class RevertCourseRevisionView(APIView):
class CoursesAutoComplete(LoginRequiredMixin, autocomplete.Select2QuerySetView):
""" Course Autocomplete. """
def get_results(self, context):
"""
Format the result set so that it can be returned as a JSON object.
Overridden from https://github.com/yourlabs/django-autocomplete-light/blob/3.1.8/src/dal_select2/views.py#L14
to include information about whether or not the suggested Course(s) use entitlements.
"""
return [
{
'id': self.get_result_value(course),
'text': self.get_result_label(course),
'uses_entitlements': course.uses_entitlements
} for course in context['object_list']
]
def get_queryset(self):
if self.q:
user = self.request.user
......
......@@ -220,6 +220,13 @@ class CourseSearchForm(forms.Form):
required=True,
)
def __init__(self, *args, **kwargs):
qs = kwargs.pop('queryset', None)
super(CourseSearchForm, self).__init__(*args, **kwargs)
if qs is not None:
self.fields['course'].queryset = qs
class CourseRunForm(BaseForm):
start = forms.DateTimeField(label=_('Course Start Date'), required=True)
......
......@@ -101,6 +101,13 @@ class Course(TimeStampedModel, ChangedByMixin):
return self.title
@property
def uses_entitlements(self):
"""
Returns a bool indicating whether or not this Course has been configured to use entitlement products.
"""
return self.version == self.ENTITLEMENT_VERSION
@property
def post_back_url(self):
return reverse('publisher:publisher_courses_edit', kwargs={'pk': self.id})
......@@ -508,6 +515,11 @@ class CourseEntitlement(TimeStampedModel):
'default': 0.00,
}
MODE_TO_SEAT_TYPE_MAPPING = {
VERIFIED: Seat.VERIFIED,
PROFESSIONAL: Seat.PROFESSIONAL
}
course = models.ForeignKey(Course, related_name='entitlements')
mode = models.CharField(max_length=63, choices=COURSE_MODE_CHOICES, verbose_name='Course mode')
price = models.DecimalField(**PRICE_FIELD_CONFIG)
......
......@@ -56,7 +56,7 @@
</div>
{% endif %}
<div class="layout-full layout">
<div class="layout-full layout js-courserun-form">
<div class="course-form">
<div class="course-information">
<fieldset class="form-group grid-container grid-manual">
......@@ -110,7 +110,7 @@
</div>
</div>
<div class="layout-full layout">
<div class="layout-full layout js-seat-form{% if hide_seat_form %} hidden{% endif %}">
<div class="course-form">
<div class="course-information">
<fieldset class="form-group grid-container grid-manual">
......@@ -136,7 +136,7 @@
</div>
</div>
{% if seat_form.price.errors %}
<div class="field-message has-error">
<div class="field-message has-error js-seat-form-errors">
<span class="field-message-content">
{{ seat_form.price.errors|escape }}
</span>
......@@ -165,6 +165,7 @@
{% block extra_js %}
<script src="{% static 'js/publisher/course-tabs.js' %}"></script>
<script src="{% static 'js/publisher/seat-type-change.js' %}"></script>
<script src="{% static 'js/publisher/toggle-seat-form.js' %}"></script>
{% endblock %}
{% block js_without_compress %}
......
......@@ -27,7 +27,7 @@ class CourseFactory(factory.DjangoModelFactory):
learner_testimonial = FuzzyText()
level_type = factory.SubFactory(factories.LevelTypeFactory)
image = factory.django.ImageField()
version = FuzzyInteger(0, 1)
version = Course.SEAT_VERSION
primary_subject = factory.SubFactory(factories.SubjectFactory)
secondary_subject = factory.SubFactory(factories.SubjectFactory)
......
......@@ -16,7 +16,8 @@ from course_discovery.apps.course_metadata.tests.factories import OrganizationFa
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole
from course_discovery.apps.publisher.mixins import check_course_organization_permission
from course_discovery.apps.publisher.models import CourseUserRole, OrganizationExtension, OrganizationUserRole, Seat
from course_discovery.apps.publisher.models import (Course, CourseUserRole, OrganizationExtension,
OrganizationUserRole, Seat)
from course_discovery.apps.publisher.tests import factories
......@@ -179,6 +180,14 @@ class CourseTests(TestCase):
course=self.course, role=PublisherUserRole.Publisher, user=self.user3
)
def test_uses_entitlements(self):
""" Verify that uses_entitlements is True when version is set to ENTITLEMENT_VERSION, and False otherwise. """
self.course.version = Course.SEAT_VERSION
assert not self.course.uses_entitlements
self.course.version = Course.ENTITLEMENT_VERSION
assert self.course.uses_entitlements
def test_str(self):
""" Verify casting an instance to a string returns a string containing the course title. """
self.assertEqual(str(self.course), self.course.title)
......
......@@ -32,8 +32,9 @@ from course_discovery.apps.publisher.dataloader.create_courses import process_co
from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing
from course_discovery.apps.publisher.forms import (AdminImportCourseForm, CourseEntitlementForm, CourseForm,
CourseRunForm, CourseSearchForm, SeatForm)
from course_discovery.apps.publisher.models import (PAID_SEATS, Course, CourseRun, CourseRunState, CourseState,
CourseUserRole, OrganizationExtension, Seat, UserAttributes)
from course_discovery.apps.publisher.models import (PAID_SEATS, Course, CourseEntitlement, CourseRun, CourseRunState,
CourseState, CourseUserRole, OrganizationExtension, Seat,
UserAttributes)
from course_discovery.apps.publisher.utils import (get_internal_users, has_role_for_course, is_internal_user,
is_project_coordinator_user, is_publisher_admin, make_bread_crumbs)
from course_discovery.apps.publisher.wrappers import CourseRunWrapper
......@@ -609,18 +610,65 @@ class CreateCourseRunView(mixins.LoginRequiredMixin, mixins.PublisherUserRequire
run_initial_data = {'pacing_type': last_run.pacing_type}
return self.run_form(initial=run_initial_data)
def _entitlement_is_valid_for_seat_creation(self, entitlement):
if entitlement is None:
return False
# The SeatForm does not support custom currency values, and assumes everything is USD.
if entitlement.currency is None or entitlement.currency.code != 'USD':
return False
if entitlement.mode not in CourseEntitlement.MODE_TO_SEAT_TYPE_MAPPING:
return False
return True
def _render_post_error(self, request, ctx_overrides=None, status=400):
context = self.get_context_data()
if ctx_overrides:
context.update(ctx_overrides)
return render(request, self.template_name, context, status=status)
def _process_post_request(self, request, parent_course, run_form, seat_form, ctx_overrides=None):
user = request.user
def _process_post_request(self, request, parent_course, context=None):
context = context or {}
run_form = self.run_form(request.POST)
context['run_form'] = run_form
if parent_course.uses_entitlements:
context['hide_seat_form'] = True
# Fail if Seat fields are present in the POST data.
seat_data_in_form = any([key for key in self.seat_form.declared_fields.keys() if key in request.POST])
if seat_data_in_form:
messages.error(
request, _('The page could not be updated. Make sure that all values are correct, then try again.')
)
return self._render_post_error(request, ctx_overrides=context)
try:
entitlement = parent_course.entitlements.get()
except (CourseEntitlement.DoesNotExist, CourseEntitlement.MultipleObjectsReturned):
entitlement = None
if not self._entitlement_is_valid_for_seat_creation(entitlement):
messages.error(
request,
_('The certificate configuration for this course is incorrect. Please fix it, then try again.')
)
return self._render_post_error(request, ctx_overrides=context)
seat_form = self.seat_form({
'type': CourseEntitlement.MODE_TO_SEAT_TYPE_MAPPING[entitlement.mode],
'price': entitlement.price
})
else:
seat_form = self.seat_form(request.POST)
context['seat_form'] = seat_form
context['hide_seat_form'] = False
course_user_roles = parent_course.course_user_roles.filter(role__in=COURSE_ROLES)
has_default_course_user_roles = course_user_roles.count() == len(COURSE_ROLES)
if not (has_default_course_user_roles or waffle.switch_is_active('disable_publisher_permissions')):
logger.error(
'Course [%s] is missing default course roles. Current roles [%s], required roles [%s]',
......@@ -635,16 +683,17 @@ class CreateCourseRunView(mixins.LoginRequiredMixin, mixins.PublisherUserRequire
'Please contact your partner manager to create default roles.'
)
)
return self._render_post_error(request, ctx_overrides=ctx_overrides)
return self._render_post_error(request, ctx_overrides=context)
if not (run_form.is_valid() and seat_form.is_valid()):
messages.error(
request, _('The page could not be updated. Make sure that all values are correct, then try again.')
)
return self._render_post_error(request, ctx_overrides=ctx_overrides)
return self._render_post_error(request, ctx_overrides=context)
try:
with transaction.atomic():
user = request.user
course_run = run_form.save(commit=False, course=parent_course, changed_by=user)
self._set_last_run_data(course_run)
seat_form.save(course_run=course_run, changed_by=user)
......@@ -665,7 +714,7 @@ class CreateCourseRunView(mixins.LoginRequiredMixin, mixins.PublisherUserRequire
error_msg = self._format_post_exception_message(ex)
messages.error(request, error_msg)
logger.exception('Unable to create course run and seat for course [%s].', parent_course.id)
return self._render_post_error(request, ctx_overrides=ctx_overrides)
return self._render_post_error(request, ctx_overrides=context)
def get_context_data(self, **kwargs):
parent_course = self._get_parent_course()
......@@ -676,18 +725,13 @@ class CreateCourseRunView(mixins.LoginRequiredMixin, mixins.PublisherUserRequire
context = {
'cancel_url': reverse('publisher:publisher_course_detail', kwargs={'pk': parent_course.pk}),
'run_form': run_form,
'seat_form': seat_form
'seat_form': seat_form,
'hide_seat_form': parent_course.uses_entitlements
}
return context
def post(self, request, *args, **kwargs):
parent_course = self._get_parent_course()
run_form = self.run_form(request.POST)
seat_form = self.seat_form(request.POST)
return self._process_post_request(request, parent_course, run_form, seat_form, ctx_overrides={
'run_form': run_form,
'seat_form': seat_form
})
return self._process_post_request(request, self._get_parent_course())
class CreateRunFromDashboardView(CreateCourseRunView):
......@@ -700,33 +744,23 @@ class CreateRunFromDashboardView(CreateCourseRunView):
def get_context_data(self, **kwargs):
context = {
'cancel_url': reverse('publisher:publisher_dashboard'),
'course_form': self.course_form(),
'course_form': self.course_form(queryset=Course.objects.none()),
'run_form': self.run_form(),
'seat_form': self.seat_form()
'seat_form': self.seat_form(),
'hide_seat_form': False
}
return context
def post(self, request, *args, **kwargs):
course_form = self.course_form(request.POST)
run_form = self.run_form(request.POST)
seat_form = self.seat_form(request.POST)
ctx_overrides = {
'course_form': course_form,
'run_form': run_form,
'seat_form': seat_form,
}
if not course_form.is_valid():
messages.error(
request, _('The page could not be updated. Make sure that all values are correct, then try again.')
)
return self._render_post_error(request, ctx_overrides=ctx_overrides)
return self._render_post_error(request, ctx_overrides={'run_form': self.run_form(request.POST)})
self.parent_course = course_form.cleaned_data.get('course')
return self._process_post_request(
request, self.parent_course, run_form, seat_form, ctx_overrides=ctx_overrides
)
return self._process_post_request(request, self.parent_course, context={'course_form': course_form})
class CourseRunEditView(mixins.LoginRequiredMixin, mixins.PublisherPermissionMixin, UpdateView):
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-02-06 14:34+0000\n"
"POT-Creation-Date: 2018-02-12 17:50+0000\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"
......@@ -3326,6 +3326,12 @@ msgstr ""
#: apps/publisher/views.py
msgid ""
"The certificate configuration for this Course is incorrect. Please fix it, "
"then try again."
msgstr ""
#: apps/publisher/views.py
msgid ""
"Your organization does not have default roles to review/approve this course-"
"run. Please contact your partner manager to create default roles."
msgstr ""
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-02-06 14:34+0000\n"
"POT-Creation-Date: 2018-02-12 17:50+0000\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: 2018-02-06 14:34+0000\n"
"POT-Creation-Date: 2018-02-12 17:50+0000\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"
......@@ -4037,6 +4037,14 @@ msgstr ""
#: apps/publisher/views.py
msgid ""
"The certificate configuration for this Course is incorrect. Please fix it, "
"then try again."
msgstr ""
"Thé çértïfïçäté çönfïgürätïön för thïs Çöürsé ïs ïnçörréçt. Pléäsé fïx ït, "
"thén trý ägäïn. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: apps/publisher/views.py
msgid ""
"Your organization does not have default roles to review/approve this course-"
"run. Please contact your partner manager to create default roles."
msgstr ""
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-02-06 14:34+0000\n"
"POT-Creation-Date: 2018-02-12 17:50+0000\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"
......
$(document).ready(function() {
var $courseRunForm = $('.js-courserun-form'),
$seatForm = $('.js-seat-form'),
$courseSelect = $('#id_course');
// If the rendered SeatForm is hidden, remove it from the DOM.
if ($seatForm.hasClass('hidden')) {
$seatForm.detach();
$seatForm.removeClass('hidden');
}
if ($courseSelect.length) {
// See https://select2.org/programmatic-control/events for information about the select2:select event.
$courseSelect.on('select2:select', function(e) {
var usesEntitlements = e.params.data.uses_entitlements;
$seatForm.detach();
if (!usesEntitlements) {
// Remove any errors that may have been initially loaded with the form.
$seatForm.find('.js-seat-form-errors').remove();
// Reset inputs before re-attaching the form.
$seatForm.find('#id_type').val('');
$seatForm.find('#seatPriceBlock').hide();
$seatForm.find('#id_price').val('0.0');
$seatForm.find('#creditPrice').hide();
$seatForm.find('#id_credit_price').val('0.0');
// Re-attach the form
$seatForm.insertAfter($courseRunForm);
}
});
}
});
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