Commit f8a73cdb by Matthew Piatetsky Committed by GitHub

Merge pull request #349 from edx/ECOM-5436

ECOM-5436 Order courses in a program
parents e0cd1654 b8100992
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
"underscore": "~1.8.3", "underscore": "~1.8.3",
"moment": "~2.13.0", "moment": "~2.13.0",
"pikaday": "https://github.com/owenmead/Pikaday.git#1.4.0", "pikaday": "https://github.com/owenmead/Pikaday.git#1.4.0",
"clipboard": "1.5.12" "clipboard": "1.5.12",
"jquery-ui": "1.10.3"
} }
} }
...@@ -493,7 +493,10 @@ class ProgramSerializer(serializers.ModelSerializer): ...@@ -493,7 +493,10 @@ class ProgramSerializer(serializers.ModelSerializer):
) )
def get_courses(self, program): def get_courses(self, program):
courses, course_runs = self.sort_courses(program) if program.order_courses_by_start_date:
courses, course_runs = self.sort_courses(program)
else:
courses, course_runs = program.courses.all(), program.course_runs
course_serializer = ProgramCourseSerializer( course_serializer = ProgramCourseSerializer(
courses, courses,
......
...@@ -434,6 +434,15 @@ class ProgramSerializerTests(TestCase): ...@@ -434,6 +434,15 @@ class ProgramSerializerTests(TestCase):
expected = self.get_expected_data(program, request) expected = self.get_expected_data(program, request)
self.assertDictEqual(dict(serializer.data), expected) self.assertDictEqual(dict(serializer.data), expected)
def test_data_without_course_sorting(self):
request = make_request()
program = self.create_program()
program.order_courses_by_start_date = False
program.save()
serializer = ProgramSerializer(program, context={'request': request})
expected = self.get_expected_data(program, request)
self.assertDictEqual(dict(serializer.data), expected)
def test_data_with_exclusions(self): def test_data_with_exclusions(self):
""" """
Verify we can specify program excluded_course_runs and the serializers will Verify we can specify program excluded_course_runs and the serializers will
......
...@@ -67,6 +67,21 @@ class ProgramViewSetTests(APITestCase): ...@@ -67,6 +67,21 @@ class ProgramViewSetTests(APITestCase):
with self.assertNumQueries(89): with self.assertNumQueries(89):
self.assert_retrieve_success(program) self.assert_retrieve_success(program)
@ddt.data(
(True),
(False),
)
def test_retrieve_with_sorting_flag(self, order_courses_by_start_date=True):
""" Verify the number of queries is the same with sorting flag set to true. """
course_list = CourseFactory.create_batch(3)
for course in course_list:
CourseRunFactory(course=course)
program = ProgramFactory(courses=course_list, order_courses_by_start_date=order_courses_by_start_date)
num_queries = 132 if order_courses_by_start_date else 114
with self.assertNumQueries(num_queries):
self.assert_retrieve_success(program)
self.assertEqual(course_list, list(program.courses.all())) # pylint: disable=no-member
def test_retrieve_without_course_runs(self): def test_retrieve_without_course_runs(self):
""" Verify the endpoint returns data for a program even if the program's courses have no course runs. """ """ Verify the endpoint returns data for a program even if the program's courses have no course runs. """
course = CourseFactory() course = CourseFactory()
......
...@@ -81,8 +81,8 @@ class ProgramAdmin(admin.ModelAdmin): ...@@ -81,8 +81,8 @@ class ProgramAdmin(admin.ModelAdmin):
'min_hours_effort_per_week', 'max_hours_effort_per_week', 'min_hours_effort_per_week', 'max_hours_effort_per_week',
) )
fields += ( fields += (
'courses', 'custom_course_runs_display', 'excluded_course_runs', 'authoring_organizations', 'courses', 'order_courses_by_start_date', 'custom_course_runs_display', 'excluded_course_runs',
'credit_backing_organizations' 'authoring_organizations', 'credit_backing_organizations'
) )
fields += filter_horizontal fields += filter_horizontal
save_error = None save_error = None
...@@ -109,12 +109,18 @@ class ProgramAdmin(admin.ModelAdmin): ...@@ -109,12 +109,18 @@ class ProgramAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
try: try:
# courses are ordered by django id, but form.cleaned_data is ordered correctly
obj.courses = form.cleaned_data.get('courses')
obj.save() obj.save()
self.save_error = False self.save_error = False
except ProgramPublisherException as ex: except ProgramPublisherException as ex:
messages.add_message(request, messages.ERROR, ex.message) messages.add_message(request, messages.ERROR, ex.message)
self.save_error = True self.save_error = True
class Media:
js = ('bower_components/jquery-ui/ui/minified/jquery-ui.min.js',
'js/sortable_select.js')
@admin.register(ProgramType) @admin.register(ProgramType)
class ProgramTypeAdmin(admin.ModelAdmin): class ProgramTypeAdmin(admin.ModelAdmin):
......
from dal import autocomplete from dal import widgets
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList from django.forms.utils import ErrorList
...@@ -8,10 +8,32 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus ...@@ -8,10 +8,32 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Program, CourseRun from course_discovery.apps.course_metadata.models import Program, CourseRun
class ProgramAdminForm(forms.ModelForm): class HackDjangoAutocompleteMixin(object):
# It seems to me there is an issue with the select 2 widget in django autocomplete.
# When the widget loads selected choices it loads them in order of django id, not the order
# they are stored in in the database. This workaround works, but not sure what approach
# would be less hacky. Perhaps opening a PR to the django autocomplete repo if this is
# fact an issue?
class QuerySetSelectMixin2(widgets.WidgetMixin):
def filter_choices_to_render(self, selected_choices):
# preserve ordering of selected_choices in queryset
# https://codybonney.com/creating-a-queryset-from-a-list-while-preserving-order-using-django/
clauses = ' '.join(['WHEN id={} THEN {}'.format(pk, i) for i, pk in enumerate(selected_choices)])
ordering = 'CASE {} END'.format(clauses)
self.choices.queryset = self.choices.queryset.filter(
pk__in=[c for c in selected_choices if c]
).extra(select={'ordering': ordering}, order_by=('ordering',))
widgets.QuerySetSelectMixin = QuerySetSelectMixin2
class ProgramAdminForm(HackDjangoAutocompleteMixin, forms.ModelForm):
class Meta: class Meta:
model = Program model = Program
fields = '__all__' fields = '__all__'
from dal import autocomplete
widgets = { widgets = {
'courses': autocomplete.ModelSelect2Multiple( 'courses': autocomplete.ModelSelect2Multiple(
url='admin_metadata:course-autocomplete', url='admin_metadata:course-autocomplete',
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from sortedm2m import fields, operations
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0028_courserun_hidden'),
]
operations = [
migrations.AddField(
model_name='program',
name='order_courses_by_start_date',
field=models.BooleanField(default=True, help_text='If this box is not checked, courses will be ordered as in the courses select box above.', verbose_name='Order Courses By Start Date'),
),
operations.AlterSortedManyToManyField(
model_name='program',
name='courses',
field=fields.SortedManyToManyField(help_text=None, related_name='programs', to='course_metadata.Course'),
),
]
...@@ -583,7 +583,11 @@ class Program(TimeStampedModel): ...@@ -583,7 +583,11 @@ class Program(TimeStampedModel):
) )
marketing_slug = models.CharField( marketing_slug = models.CharField(
help_text=_('Slug used to generate links to the marketing site'), blank=True, max_length=255, db_index=True) help_text=_('Slug used to generate links to the marketing site'), blank=True, max_length=255, db_index=True)
courses = models.ManyToManyField(Course, related_name='programs') courses = SortedManyToManyField(Course, related_name='programs')
order_courses_by_start_date = models.BooleanField(
default=True, verbose_name='Order Courses By Start Date',
help_text=_('If this box is not checked, courses will be ordered as in the courses select box above.')
)
# NOTE (CCB): Editors of this field should validate the values to ensure only CourseRuns associated # NOTE (CCB): Editors of this field should validate the values to ensure only CourseRuns associated
# with related Courses are stored. # with related Courses are stored.
excluded_course_runs = models.ManyToManyField(CourseRun, blank=True) excluded_course_runs = models.ManyToManyField(CourseRun, blank=True)
......
...@@ -251,6 +251,7 @@ class ProgramFactory(factory.django.DjangoModelFactory): ...@@ -251,6 +251,7 @@ class ProgramFactory(factory.django.DjangoModelFactory):
min_hours_effort_per_week = FuzzyInteger(2) min_hours_effort_per_week = FuzzyInteger(2)
max_hours_effort_per_week = FuzzyInteger(4) max_hours_effort_per_week = FuzzyInteger(4)
credit_redemption_overview = FuzzyText() credit_redemption_overview = FuzzyText()
order_courses_by_start_date = True
@factory.post_generation @factory.post_generation
def courses(self, create, extracted, **kwargs): def courses(self, create, extracted, **kwargs):
......
function updateSelect2Data(visibleCourseTitles){
var i, j,
visibleCourseTitlesLength,
selectOptionsLength,
visibleCourseTitles = [],
selectOptions = [],
items = [],
selectOptionsSelector = '.field-courses .select2-hidden-accessible';
$('.field-courses .select2-selection__choice').each(function(index, value){
if (value.title){
visibleCourseTitles.push(value.title);
}
});
$('.field-courses .select2-hidden-accessible option').each(function(index, value){
selectOptions.push({id: value.value, text: value.text});
});
// Update select2 options with new data
visibleCourseTitlesLength = visibleCourseTitles.length;
selectOptionsLength = selectOptions.length;
for (i = 0; i < visibleCourseTitlesLength; i++) {
for (j = 0; j < selectOptionsLength; j++) {
if (selectOptions[j].text === visibleCourseTitles[i]){
items.push('<option selected="selected" value="' + selectOptions[j].id + '">' +
selectOptions[j].text + '</option>'
);
}
}
}
if (items){
$(selectOptionsSelector).html(items.join('\n'));
}
}
$(window).load(function(){
$(function() {
var domSelector = '.field-courses .select2-selection--multiple';
$('.field-courses ul.select2-selection__rendered').sortable({
containment: 'parent',
update: updateSelect2Data
})
})
});
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