Commit 4ac7f58f by Renzo Lucioni Committed by GitHub

Merge pull request #409 from edx/clintonb/program-admin-quick-fix

Fixed bug on program admin page
parents df21d5db 8b2b0a2b
from django.contrib import admin, messages
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from course_discovery.apps.course_metadata.forms import ProgramAdminForm
from course_discovery.apps.course_metadata.models import * # pylint: disable=wildcard-import
......@@ -85,38 +87,47 @@ class ProgramAdmin(admin.ModelAdmin):
'authoring_organizations', 'credit_backing_organizations'
)
fields += filter_horizontal
save_error = None
save_error = False
def custom_course_runs_display(self, obj):
return ", ".join([str(run) for run in obj.course_runs])
return mark_safe('<br>'.join([str(run) for run in obj.course_runs]))
custom_course_runs_display.short_description = "Included course runs"
custom_course_runs_display.short_description = _('Included course runs')
def _redirect_course_run_update_page(self, obj):
""" Returns a response redirect to a page where the user can update the
course runs for the program being edited.
Returns:
HttpResponseRedirect
"""
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
def response_add(self, request, obj, post_url_continue=None):
if self.save_error:
return self.response_post_save_add(request, obj)
else:
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
return self._redirect_course_run_update_page(obj)
def response_change(self, request, obj):
if self.save_error:
return self.response_post_save_change(request, obj)
else:
if any(status in request.POST for status in ['_continue', '_save']):
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
return self._redirect_course_run_update_page(obj)
else:
return HttpResponseRedirect(reverse('admin:course_metadata_program_add'))
def save_model(self, request, obj, form, change):
try:
# courses are ordered by django id, but form.cleaned_data is ordered correctly
obj.courses = form.cleaned_data.get('courses')
obj.authoring_organizations = form.cleaned_data.get('authoring_organizations')
obj.credit_backing_organizations = form.cleaned_data.get('credit_backing_organizations')
obj.save()
super().save_model(request, obj, form, change)
self.save_error = False
except ProgramPublisherException as ex:
messages.add_message(request, messages.ERROR, ex.message)
except ProgramPublisherException:
# TODO Redirect the user back to the form so that he/she can try again.
logger.exception('An error occurred while publishing the program [%s] to the marketing site.', obj.uuid)
msg = _('An error occurred while publishing the program to the marketing site. Please try again. '
'If the error persists, please contact the Engineering Team.')
messages.add_message(request, messages.ERROR, msg)
self.save_error = True
class Media:
......
from dal import widgets
from dal import autocomplete
from django import forms
from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList
......@@ -8,32 +8,30 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Program, CourseRun
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?
def filter_choices_to_render_with_order_preserved(self, selected_choices):
"""
Preserves ordering of selected_choices when creating the choices queryset.
class QuerySetSelectMixin2(widgets.WidgetMixin):
See https://codybonney.com/creating-a-queryset-from-a-list-while-preserving-order-using-django.
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',))
django-autocomplete's definition of this method on QuerySetSelectMixin loads selected choices in
order of primary key instead of the order in which the choices are actually stored.
"""
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 ProgramAdminForm(forms.ModelForm):
class Meta:
model = Program
fields = '__all__'
from dal import autocomplete
# Monkey patch filter_choices_to_render with our own definition which preserves ordering.
autocomplete.ModelSelect2Multiple.filter_choices_to_render = filter_choices_to_render_with_order_preserved
widgets = {
'courses': autocomplete.ModelSelect2Multiple(
url='admin_metadata:course-autocomplete',
......
......@@ -4,19 +4,25 @@ import ddt
from django.core.urlresolvers import reverse
from django.test import TestCase, LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select
from selenium.webdriver.support.wait import WebDriverWait
from course_discovery.apps.core.models import Partner
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.forms import ProgramAdminForm
from course_discovery.apps.course_metadata.models import Program, ProgramType
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.core.tests.helpers import make_image_file
# pylint: disable=no-member
@ddt.ddt
class AdminTests(TestCase):
""" Tests Admin page."""
def setUp(self):
super(AdminTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
......@@ -185,71 +191,144 @@ class AdminTests(TestCase):
class ProgramAdminFunctionalTests(LiveServerTestCase):
""" Functional Tests for Admin page."""
create_view_name = 'admin:course_metadata_program_add'
edit_view_name = 'admin:course_metadata_program_change'
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.browser = webdriver.Firefox()
cls.browser.implicitly_wait(10)
cls.browser.set_window_size(1024, 768)
@classmethod
def tearDownClass(cls):
cls.browser.quit()
super().tearDownClass()
@classmethod
def _build_url(cls, path):
""" Returns a URL for the live test server. """
return cls.live_server_url + path
@classmethod
def _wait_for_page_load(cls, body_class):
""" Wait for the page to load. """
WebDriverWait(cls.browser, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, 'body.' + body_class))
)
def setUp(self):
super(ProgramAdminFunctionalTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=self.user.username, password=USER_PASSWORD)
super().setUp()
self.course_runs = factories.CourseRunFactory.create_batch(2)
self.courses = [course_run.course for course_run in self.course_runs]
self.excluded_course_run = factories.CourseRunFactory(course=self.courses[0])
self.program = factories.ProgramFactory(
courses=self.courses, excluded_course_runs=[self.excluded_course_run]
courses=self.courses, excluded_course_runs=[self.excluded_course_run], status=ProgramStatus.Unpublished
)
self.browser = webdriver.Firefox()
# Get Page
domain = self.live_server_url
url = reverse('admin:course_metadata_program_change', args=(self.program.id,))
self.browser.get(domain + url)
# Login
username = self.browser.find_element_by_id('id_username')
password = self.browser.find_element_by_id('id_password')
username.send_keys(self.user.username)
password.send_keys(USER_PASSWORD)
self.user = UserFactory(is_staff=True, is_superuser=True)
self._login()
def _login(self):
""" Log into Django admin. """
self.browser.get(self._build_url(reverse('admin:login')))
self.browser.find_element_by_id('id_username').send_keys(self.user.username)
self.browser.find_element_by_id('id_password').send_keys(USER_PASSWORD)
self.browser.find_element_by_css_selector('input[type=submit]').click()
self._wait_for_page_load('dashboard')
def _wait_for_add_edit_page_to_load(self):
self._wait_for_page_load('change-form')
def _wait_for_excluded_course_runs_page_to_load(self):
self._wait_for_page_load('change-program-excluded-course-runs-form')
def _navigate_to_edit_page(self):
url = self._build_url(reverse(self.edit_view_name, args=(self.program.id,)))
self.browser.get(url)
self._wait_for_add_edit_page_to_load()
def _select_option(self, select_id, option_value):
select = Select(self.browser.find_element_by_id(select_id))
select.select_by_value(option_value)
def _submit_program_form(self):
self.browser.find_element_by_css_selector('input[type=submit][name=_save]').click()
self._wait_for_excluded_course_runs_page_to_load()
def assert_form_fields_present(self):
""" Asserts the correct fields are rendered on the form. """
# Check the model fields
actual = []
for element in self.browser.find_elements_by_class_name('form-row'):
actual += [_class for _class in element.get_attribute('class').split(' ') if _class.startswith('field-')]
expected = [
'field-title', 'field-subtitle', 'field-status', 'field-type', 'field-partner', 'field-banner_image',
'field-banner_image_url', 'field-card_image_url', 'field-marketing_slug', 'field-overview',
'field-credit_redemption_overview', 'field-video', 'field-weeks_to_complete',
'field-min_hours_effort_per_week', 'field-max_hours_effort_per_week', 'field-courses',
'field-order_courses_by_start_date', 'field-custom_course_runs_display', 'field-excluded_course_runs',
'field-authoring_organizations', 'field-credit_backing_organizations', 'field-job_outlook_items',
'field-expected_learning_items',
]
self.assertEqual(actual, expected)
# Check the inline fields
expected = ['Program_faq-group', 'Program_individual_endorsements-group',
'Program_corporate_endorsements-group']
actual = [element.get_attribute('id') for element in self.browser.find_elements_by_class_name('inline-group')]
self.assertEqual(actual, expected)
def test_program_creation(self):
url = self._build_url(reverse(self.create_view_name))
self.browser.get(url)
self._wait_for_add_edit_page_to_load()
self.assert_form_fields_present()
program = factories.ProgramFactory.build(
partner=Partner.objects.first(),
status=ProgramStatus.Unpublished,
type=ProgramType.objects.first()
)
self.browser.find_element_by_id('id_title').send_keys(program.title)
self.browser.find_element_by_id('id_subtitle').send_keys(program.subtitle)
self._select_option('id_status', program.status)
self._select_option('id_type', str(program.type.id))
self._select_option('id_partner', str(program.partner.id))
self._submit_program_form()
actual = Program.objects.latest()
self.assertEqual(actual.title, program.title)
self.assertEqual(actual.subtitle, program.subtitle)
self.assertEqual(actual.status, program.status)
self.assertEqual(actual.type, program.type)
self.assertEqual(actual.partner, program.partner)
def test_program_update(self):
self._navigate_to_edit_page()
self.assert_form_fields_present()
title = 'Test Program'
subtitle = 'This is a test.'
# Update the program
data = (
('title', title),
('subtitle', subtitle),
)
for field, value in data:
element = self.browser.find_element_by_id('id_' + field)
element.clear()
element.send_keys(value)
self._submit_program_form()
# This window size is close to the window size when running on travis
self.browser.set_window_size(548, 768)
def tearDown(self):
super(ProgramAdminFunctionalTests, self).tearDown()
self.browser.quit()
def test_all_fields(self):
# Make sure that all expected fields are present
classes = [css_class for field in self.browser.find_elements_by_class_name('form-row')
for css_class in field.get_attribute('class').split(' ')
if css_class.startswith('field-') or css_class.startswith('dynamic-')]
expected_classes = ['field-title', 'field-subtitle', 'field-status', 'field-type',
'field-partner', 'field-banner_image', 'field-banner_image_url',
'field-card_image_url', 'field-marketing_slug', 'field-overview',
'field-credit_redemption_overview', 'field-video',
'field-weeks_to_complete', 'field-min_hours_effort_per_week',
'field-max_hours_effort_per_week', 'field-courses',
'field-order_courses_by_start_date', 'field-custom_course_runs_display',
'field-excluded_course_runs', 'field-authoring_organizations',
'field-credit_backing_organizations', 'field-job_outlook_items',
'field-expected_learning_items', 'dynamic-Program_faq',
'dynamic-Program_individual_endorsements',
'dynamic-Program_corporate_endorsements']
self.assertEqual(classes, expected_classes)
def test_sortable_select_drag_and_drop(self):
# Get order of select elements
hidden_options_text = [el.text for el in
self.browser.find_elements_by_css_selector('.field-courses option')]
first_select_element = self.browser.find_element_by_css_selector('.field-courses .select2-selection__choice')
# Drag and drop
first_select_element.click()
ActionChains(self.browser).drag_and_drop_by_offset(first_select_element, 500, 0).perform()
# Simulate expected drag and drop
hidden_options_text = [hidden_options_text[1], hidden_options_text[0]]
# Get actual results of drag and drop
new_hidden_options_text = [el.text for el in
self.browser.find_elements_by_css_selector('.field-courses option')]
self.assertEqual(hidden_options_text, new_hidden_options_text)
# Verify the program was updated
self.program = Program.objects.get(pk=self.program.pk)
self.assertEqual(self.program.title, title)
self.assertEqual(self.program.subtitle, subtitle)
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect, Http404
from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView, UpdateView
from course_discovery.apps.course_metadata.forms import CourseRunSelectionForm
......@@ -25,7 +26,10 @@ class CourseRunSelectionAdmin(UpdateView):
def get_context_data(self, **kwargs):
if self.request.user.is_authenticated() and self.request.user.is_staff:
context = super(CourseRunSelectionAdmin, self).get_context_data(**kwargs)
context['program_id'] = self.object.id
context.update({
'program_id': self.object.id,
'title': _('Change program excluded course runs')
})
return context
raise Http404
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-10-28 14:40+0500\n"
"POT-Creation-Date: 2016-10-31 19:34-0400\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"
......@@ -196,6 +196,16 @@ msgstr ""
msgid "Partners"
msgstr ""
#: apps/course_metadata/admin.py
msgid "Included course runs"
msgstr ""
#: apps/course_metadata/admin.py
msgid ""
"An error occurred while publishing the program to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
#: apps/course_metadata/choices.py apps/publisher/models.py
msgid "Published"
msgstr ""
......@@ -391,6 +401,10 @@ msgstr ""
msgid "The description of credit redemption for courses in program"
msgstr ""
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgstr ""
#: apps/edx_haystack_extensions/models.py
msgid "Function Score"
msgstr ""
......@@ -620,7 +634,7 @@ msgid ""
"trademarks or trademarks of %(link_start)sedX Inc.%(link_end)s"
msgstr ""
#: templates/footer.html templates/footer.html.py
#: templates/footer.html
msgid "Powered by Open edX"
msgstr ""
......@@ -658,10 +672,6 @@ msgid "Sign Out"
msgstr ""
#: templates/metadata/admin/course_run.html
msgid "CourseRun Selection Form"
msgstr ""
#: templates/metadata/admin/course_run.html
msgid "Cancel"
msgstr ""
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-10-28 14:40+0500\n"
"POT-Creation-Date: 2016-10-31 19:34-0400\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: 2016-10-28 14:40+0500\n"
"POT-Creation-Date: 2016-10-31 19:34-0400\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"
......@@ -237,6 +237,24 @@ msgstr "Pärtnér Ⱡ'σяєм ιρѕυм #"
msgid "Partners"
msgstr "Pärtnérs Ⱡ'σяєм ιρѕυм ∂#"
#: apps/course_metadata/admin.py
msgid "Included course runs"
msgstr "Ìnçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: apps/course_metadata/admin.py
msgid ""
"An error occurred while publishing the program to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
"Àn érrör öççürréd whïlé püßlïshïng thé prögräm tö thé märkétïng sïté. Pléäsé"
" trý ägäïn. Ìf thé érrör pérsïsts, pléäsé çöntäçt thé Éngïnéérïng Téäm. "
"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ "
"тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм,"
" qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ "
"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє "
"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт "
"ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм #"
#: apps/course_metadata/choices.py apps/publisher/models.py
msgid "Published"
msgstr "Püßlïshéd Ⱡ'σяєм ιρѕυм ∂σł#"
......@@ -484,6 +502,11 @@ msgstr ""
"Thé désçrïptïön öf çrédït rédémptïön för çöürsés ïn prögräm Ⱡ'σяєм ιρѕυм "
"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgstr ""
"Çhängé prögräm éxçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#"
#: apps/edx_haystack_extensions/models.py
msgid "Function Score"
msgstr "Fünçtïön Sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
......@@ -741,7 +764,7 @@ msgstr ""
"trädémärks ör trädémärks öf %(link_start)sédX Ìnç.%(link_end)s Ⱡ'σяєм ιρѕυм "
"∂σł#"
#: templates/footer.html templates/footer.html.py
#: templates/footer.html
msgid "Powered by Open edX"
msgstr "Pöwéréd ßý Öpén édX Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
......@@ -779,10 +802,6 @@ msgid "Sign Out"
msgstr "Sïgn Öüt Ⱡ'σяєм ιρѕυм ∂#"
#: templates/metadata/admin/course_run.html
msgid "CourseRun Selection Form"
msgstr "ÇöürséRün Séléçtïön Förm Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#"
#: templates/metadata/admin/course_run.html
msgid "Cancel"
msgstr "Çänçél Ⱡ'σяєм ιρѕυ#"
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-10-28 14:40+0500\n"
"POT-Creation-Date: 2016-10-31 19:34-0400\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"
......
{% extends 'admin/base_site.html' %}
{% load i18n %}
{% block title %}
{% trans "CourseRun Selection Form" %}
{% endblock title %}
{% block content %}
{% block bodyclass %}
change-program-excluded-course-runs-form
{% endblock %}
{% block content %}
<form class="form" method="post" action="">
{% csrf_token %}
<fieldset class="module aligned ">
......@@ -30,5 +30,4 @@
</div>
{% endblock %}
</form>
{% endblock %}
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