Commit edec9391 by muhammad-ammar Committed by Muhammad Ammar

paginate courses list page on backend

EDUCATOR-882
parent 81a485b0
"""
Publisher courses serializers.
"""
from django.core.exceptions import ObjectDoesNotExist
from django.urls import reverse
from django.utils.translation import ugettext as _
from rest_framework import serializers
from course_discovery.apps.publisher.mixins import check_course_organization_permission
from course_discovery.apps.publisher.models import OrganizationExtension
from course_discovery.apps.publisher.utils import has_role_for_course
class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Publisher courses list serializer.
"""
course_title = serializers.SerializerMethodField()
organization_name = serializers.SerializerMethodField()
project_coordinator_name = serializers.SerializerMethodField()
publisher_course_runs_count = serializers.SerializerMethodField()
course_team_status = serializers.SerializerMethodField()
internal_user_status = serializers.SerializerMethodField()
edit_url = serializers.SerializerMethodField()
def get_course_title(self, course):
"""
Returns a dict containing course `title` and `url`.
"""
publisher_hide_features_for_pilot = self.context['publisher_hide_features_for_pilot']
return {
'title': course.title,
'url': None if publisher_hide_features_for_pilot else reverse(
'publisher:publisher_course_detail', kwargs={'pk': course.id}
)
}
def get_organization_name(self, course):
"""
Returns course organization name.
"""
return course.organization_name
def get_project_coordinator_name(self, course):
"""
Returns course project coordinator name.
"""
project_coordinator = course.project_coordinator
return project_coordinator.full_name if project_coordinator else ''
def get_publisher_course_runs_count(self, course):
"""
Returns count of course runs for a course.
"""
try:
return course.publisher_course_runs.count()
except ObjectDoesNotExist:
return 0
def get_course_team_status(self, course):
"""
Returns a dict containing `status` and `date` for course team status.
"""
try:
course_team_status = course.course_state.course_team_status
except ObjectDoesNotExist:
return {
'status': '',
'date': ''
}
course_team_status_date = course_team_status.get('date', '')
return {
'status': course_team_status.get('status_text', ''),
'date': course_team_status_date and course_team_status_date.strftime('%m/%d/%y')
}
def get_internal_user_status(self, course):
"""
Returns a dict containing `status` and `date` for internal user status.
"""
try:
internal_user_status = course.course_state.internal_user_status
except ObjectDoesNotExist:
return {
'status': '',
'date': ''
}
internal_user_status_date = internal_user_status.get('date', '')
return {
'status': internal_user_status.get('status_text', ''),
'date': internal_user_status_date and internal_user_status_date.strftime('%m/%d/%y')
}
def get_edit_url(self, course):
"""
Returns a dict containing `title` and `url` to edit a course.
"""
courses_edit_url = None
publisher_hide_features_for_pilot = self.context['publisher_hide_features_for_pilot']
if not publisher_hide_features_for_pilot and self.can_edit_course(course, self.context['user']):
courses_edit_url = reverse('publisher:publisher_courses_edit', kwargs={'pk': course.id})
return {
'title': _('Edit'),
'url': courses_edit_url
}
@classmethod
def can_edit_course(cls, course, user):
"""
Check if user has permissions on course.
Arguments:
course: course instance to be serialized
user: currently logedin user
Returns:
bool: Whether the logedin user has permission or not.
"""
try:
return check_course_organization_permission(
user, course, OrganizationExtension.EDIT_COURSE
) and has_role_for_course(course, user)
except ObjectDoesNotExist:
return False
from django import template
from course_discovery.apps.publisher.mixins import check_course_organization_permission
from course_discovery.apps.publisher.models import OrganizationExtension
from course_discovery.apps.publisher.utils import has_role_for_course
register = template.Library()
def can_edit(course, user, permission):
return check_course_organization_permission(
user, course, permission
) and has_role_for_course(course, user)
@register.filter
def can_edit_course(course, user):
return can_edit(course, user, OrganizationExtension.EDIT_COURSE)
# pylint: disable=no-member # pylint: disable=no-member
import json import json
import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
import ddt import ddt
import factory import factory
import mock
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
...@@ -14,7 +16,6 @@ from django.forms import model_to_dict ...@@ -14,7 +16,6 @@ from django.forms import model_to_dict
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from mock import patch
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from pytz import timezone from pytz import timezone
from testfixtures import LogCapture from testfixtures import LogCapture
...@@ -37,7 +38,8 @@ from course_discovery.apps.publisher.tests import factories ...@@ -37,7 +38,8 @@ from course_discovery.apps.publisher.tests import factories
from course_discovery.apps.publisher.tests.utils import create_non_staff_user_and_login from course_discovery.apps.publisher.tests.utils import create_non_staff_user_and_login
from course_discovery.apps.publisher.utils import is_email_notification_enabled from course_discovery.apps.publisher.utils import is_email_notification_enabled
from course_discovery.apps.publisher.views import logger as publisher_views_logger from course_discovery.apps.publisher.views import logger as publisher_views_logger
from course_discovery.apps.publisher.views import CourseRunDetailView, get_course_role_widgets_data from course_discovery.apps.publisher.views import (COURSES_ALLOWED_PAGE_SIZES, CourseRunDetailView,
get_course_role_widgets_data)
from course_discovery.apps.publisher.wrappers import CourseRunWrapper from course_discovery.apps.publisher.wrappers import CourseRunWrapper
from course_discovery.apps.publisher_comments.models import CommentTypeChoices from course_discovery.apps.publisher_comments.models import CommentTypeChoices
from course_discovery.apps.publisher_comments.tests.factories import CommentFactory from course_discovery.apps.publisher_comments.tests.factories import CommentFactory
...@@ -157,7 +159,7 @@ class CreateCourseViewTests(SiteMixin, TestCase): ...@@ -157,7 +159,7 @@ class CreateCourseViewTests(SiteMixin, TestCase):
self._assert_records(1) self._assert_records(1)
data = {'number': 'course_2', 'image': make_image_file('test_banner.jpg')} data = {'number': 'course_2', 'image': make_image_file('test_banner.jpg')}
course_dict = self._post_data(data, self.course) course_dict = self._post_data(data, self.course)
with patch.object(Course, "save") as mock_method: with mock.patch.object(Course, "save") as mock_method:
mock_method.side_effect = IntegrityError mock_method.side_effect = IntegrityError
response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict, files=data['image']) response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict, files=data['image'])
...@@ -355,7 +357,7 @@ class CreateCourseRunViewTests(SiteMixin, TestCase): ...@@ -355,7 +357,7 @@ class CreateCourseRunViewTests(SiteMixin, TestCase):
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
with patch('django.forms.models.BaseModelForm.is_valid') as mocked_is_valid: with mock.patch('django.forms.models.BaseModelForm.is_valid') as mocked_is_valid:
mocked_is_valid.return_value = True mocked_is_valid.return_value = True
with LogCapture(publisher_views_logger.name) as log_capture: with LogCapture(publisher_views_logger.name) as log_capture:
response = self.client.post( response = self.client.post(
...@@ -792,7 +794,7 @@ class CourseRunDetailTests(SiteMixin, TestCase): ...@@ -792,7 +794,7 @@ class CourseRunDetailTests(SiteMixin, TestCase):
""" """
non_staff_user, group = create_non_staff_user_and_login(self) # pylint: disable=unused-variable non_staff_user, group = create_non_staff_user_and_login(self) # pylint: disable=unused-variable
page_url = reverse('publisher:publisher_course_run_detail', args=[self.course_run.id]) page_url = reverse('publisher:publisher_course_run_detail', args=[self.course_run.id])
with patch.object(CourseRunDetailView, 'get_object', return_value=non_staff_user): with mock.patch.object(CourseRunDetailView, 'get_object', return_value=non_staff_user):
response = self.client.get(page_url) response = self.client.get(page_url)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
...@@ -1603,7 +1605,41 @@ class ToggleEmailNotificationTests(SiteMixin, TestCase): ...@@ -1603,7 +1605,41 @@ class ToggleEmailNotificationTests(SiteMixin, TestCase):
self.assertEqual(is_email_notification_enabled(user), is_enabled) self.assertEqual(is_email_notification_enabled(user), is_enabled)
class CourseListViewTests(SiteMixin, TestCase): class PaginationMixin(object):
"""
Common methods to be used for Paginated views.
"""
def get_courses(self, status_code=200, content_type='application/json', **kwargs):
"""
Make get request with specified params.
Arguments:
status_code (int): used to verify the received response status code
content_type (st): content type of get request
kwargs (dict): extra kwargs like `query_params` to be used in get request
Returns:
courses (list): list of courses
"""
query_params = kwargs.get('query_params', {})
# draw query parameter is send by jquery DataTables in all ajax requests
# https://datatables.net/manual/server-side
draw = 1
query_params['draw'] = draw
response = self.client.get(self.courses_url, query_params, HTTP_ACCEPT=content_type)
self.assertEqual(response.status_code, status_code)
if content_type == 'application/json':
json_response = response.json()
self.assertEqual(json_response['draw'], draw)
return json_response['courses']
else:
return json.loads(response.context_data['courses'].decode('utf-8'))
class CourseListViewTests(SiteMixin, PaginationMixin, TestCase):
""" Tests for `CourseListView` """ """ Tests for `CourseListView` """
def setUp(self): def setUp(self):
...@@ -1612,6 +1648,9 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1612,6 +1648,9 @@ class CourseListViewTests(SiteMixin, TestCase):
self.course = self.courses[0] self.course = self.courses[0]
self.user = UserFactory() self.user = UserFactory()
for course in self.courses:
factories.CourseStateFactory(course=course, owner_role=PublisherUserRole.MarketingReviewer)
self.client.login(username=self.user.username, password=USER_PASSWORD) self.client.login(username=self.user.username, password=USER_PASSWORD)
self.courses_url = reverse('publisher:publisher_courses') self.courses_url = reverse('publisher:publisher_courses')
...@@ -1622,7 +1661,7 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1622,7 +1661,7 @@ class CourseListViewTests(SiteMixin, TestCase):
def test_courses_with_admin(self): def test_courses_with_admin(self):
""" Verify that admin user can see all courses on course list page. """ """ Verify that admin user can see all courses on course list page. """
self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME)) self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME))
self.assert_course_list_page(course_count=10, queries_executed=32) self.assert_course_list_page(course_count=10, queries_executed=33)
def test_courses_with_course_user_role(self): def test_courses_with_course_user_role(self):
""" Verify that internal user can see course on course list page. """ """ Verify that internal user can see course on course list page. """
...@@ -1630,7 +1669,7 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1630,7 +1669,7 @@ class CourseListViewTests(SiteMixin, TestCase):
for course in self.courses: for course in self.courses:
factories.CourseUserRoleFactory(course=course, user=self.user, role=InternalUserRole.Publisher) factories.CourseUserRoleFactory(course=course, user=self.user, role=InternalUserRole.Publisher)
self.assert_course_list_page(course_count=10, queries_executed=33) self.assert_course_list_page(course_count=10, queries_executed=34)
def test_courses_with_permission(self): def test_courses_with_permission(self):
""" Verify that user can see course with permission on course list page. """ """ Verify that user can see course with permission on course list page. """
...@@ -1641,7 +1680,7 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1641,7 +1680,7 @@ class CourseListViewTests(SiteMixin, TestCase):
course.organizations.add(organization_extension.organization) course.organizations.add(organization_extension.organization)
assign_perm(OrganizationExtension.VIEW_COURSE, organization_extension.group, organization_extension) assign_perm(OrganizationExtension.VIEW_COURSE, organization_extension.group, organization_extension)
self.assert_course_list_page(course_count=10, queries_executed=65) self.assert_course_list_page(course_count=10, queries_executed=66)
def assert_course_list_page(self, course_count, queries_executed): def assert_course_list_page(self, course_count, queries_executed):
""" Dry method to assert course list page content. """ """ Dry method to assert course list page content. """
...@@ -1651,27 +1690,32 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1651,27 +1690,32 @@ class CourseListViewTests(SiteMixin, TestCase):
self.assertContains(response, '{} Courses'.format(course_count)) self.assertContains(response, '{} Courses'.format(course_count))
self.assertContains(response, 'Create New Course') self.assertContains(response, 'Create New Course')
if course_count > 0: if course_count > 0:
self.assertContains(response, self.course.title) self.assertEqual(response.status_code, 200)
courses = json.loads(response.context_data['courses'].decode('utf-8'))
self.assertTrue(self.course.title in [course['course_title']['title'] for course in courses])
def test_page_with_enable_waffle_switch(self): def test_page_with_enable_waffle_switch(self):
""" """
Verify that edit button will not be shown if 'publisher_hide_features_for_pilot' activated. Verify that edit button will not be shown if 'publisher_hide_features_for_pilot' activated.
""" """
edit_url = {'title': 'Edit', 'url': reverse('publisher:publisher_courses_edit', args=[self.course.id])}
factories.CourseUserRoleFactory(course=self.course, user=self.user, role=PublisherUserRole.CourseTeam) factories.CourseUserRoleFactory(course=self.course, user=self.user, role=PublisherUserRole.CourseTeam)
organization_extension = factories.OrganizationExtensionFactory() organization_extension = factories.OrganizationExtensionFactory()
self.course.organizations.add(organization_extension.organization) self.course.organizations.add(organization_extension.organization)
self.user.groups.add(organization_extension.group) self.user.groups.add(organization_extension.group)
assign_perm(OrganizationExtension.VIEW_COURSE, organization_extension.group, organization_extension) assign_perm(OrganizationExtension.VIEW_COURSE, organization_extension.group, organization_extension)
assign_perm(OrganizationExtension.EDIT_COURSE, organization_extension.group, organization_extension) assign_perm(OrganizationExtension.EDIT_COURSE, organization_extension.group, organization_extension)
response = self.client.get(self.courses_url) response = self.get_courses()
self.assertContains(response, 'Edit') self.assertEqual(response[0]['edit_url'], edit_url)
toggle_switch('publisher_hide_features_for_pilot', True) toggle_switch('publisher_hide_features_for_pilot', True)
with self.assertNumQueries(18): with self.assertNumQueries(19):
response = self.client.get(self.courses_url) response = self.get_courses()
self.assertNotContains(response, 'Edit') edit_url['url'] = None
self.assertEqual(response[0]['edit_url'], edit_url)
def test_page_with_disable_waffle_switch(self): def test_page_with_disable_waffle_switch(self):
""" """
...@@ -1687,13 +1731,233 @@ class CourseListViewTests(SiteMixin, TestCase): ...@@ -1687,13 +1731,233 @@ class CourseListViewTests(SiteMixin, TestCase):
toggle_switch('publisher_hide_features_for_pilot', False) toggle_switch('publisher_hide_features_for_pilot', False)
with self.assertNumQueries(22): with self.assertNumQueries(23):
response = self.client.get(self.courses_url) response = self.client.get(self.courses_url)
self.assertContains(response, 'Edit') self.assertContains(response, 'Edit')
class CourseDetailViewTests(SiteMixin, TestCase): @ddt.ddt
@mock.patch('course_discovery.apps.publisher.views.COURSES_DEFAULT_PAGE_SIZE', 2)
@mock.patch('course_discovery.apps.publisher.views.COURSES_ALLOWED_PAGE_SIZES', (2, 3, 4))
class CourseListViewPaginationTests(PaginationMixin, TestCase):
""" Pagination tests for `CourseListView` """
def setUp(self):
super(CourseListViewPaginationTests, self).setUp()
self.courses = []
self.course_titles = [
'course title 16', 'course title 37', 'course title 19', 'course title 37', 'course title 25',
'course title 25', 'course title 10', 'course title 13', 'course title 28', 'course title 13'
]
self.course_organizations = [
'zeroX', 'deepX', 'fuzzyX', 'arkX', 'maX', 'pizzaX', 'maX', 'arkX', 'fuzzyX', 'zeroX',
]
self.course_dates = [
datetime(2017, 1, 10), datetime(2019, 2, 25), datetime(2017, 3, 20), datetime(2018, 3, 24),
datetime(2017, 2, 21), datetime(2015, 1, 22), datetime(2018, 2, 23), datetime(2017, 1, 21),
datetime(2019, 1, 24), datetime(2017, 2, 11),
]
# create 10 courses with related objects
for index in range(10):
course = factories.CourseFactory(title=self.course_titles[index])
for _ in range(random.randrange(1, 10)):
factories.CourseRunFactory(course=course)
course_state = factories.CourseStateFactory(course=course, owner_role=PublisherUserRole.MarketingReviewer)
course_state.owner_role_modified = self.course_dates[index]
course_state.save()
course.organizations.add(OrganizationFactory(key=self.course_organizations[index]))
self.courses.append(course)
self.course = self.courses[0]
self.user = UserFactory()
self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME))
self.client.login(username=self.user.username, password=USER_PASSWORD)
self.courses_url = reverse('publisher:publisher_courses')
self.sort_directions = {
'asc': False,
'desc': True,
}
@ddt.data(
{'page_size': '', 'expected': 2},
{'page_size': 2, 'expected': 2},
{'page_size': 3, 'expected': 3},
{'page_size': 4, 'expected': 4},
{'page_size': -1, 'expected': 2},
)
@ddt.unpack
def test_page_size(self, page_size, expected):
""" Verify that page size is working as expected. """
courses = self.get_courses(query_params={'pageSize': page_size})
self.assertEqual(len(courses), expected)
@ddt.data(
{'page': '', 'expected': 1},
{'page': 2, 'expected': 2},
{'page': 10, 'expected': None},
)
@ddt.unpack
def test_page_number(self, page, expected):
"""
Verify that page number is working as expected.
Note: We have total 3 pages. If page number is invalid than 404 response will be received.
"""
response = self.client.get(self.courses_url, {'pageSize': 4, 'page': page})
if response.status_code == 200:
self.assertEqual(response.context_data['page_obj'].number, expected)
else:
self.assertEqual(response.status_code, 404)
def test_content_type_text_html(self):
"""
Verify that get request with text/html content type is working as expected.
"""
courses = self.get_courses(content_type='text/html')
self.assertEqual(len(courses), 2)
@ddt.data(
{'field': 'title', 'column': 0, 'direction': 'asc'},
{'field': 'title', 'column': 0, 'direction': 'desc'},
)
@ddt.unpack
def test_ordering_by_title(self, field, column, direction):
""" Verify that ordering by title is working as expected. """
for page in (1, 2, 3):
courses = self.get_courses(
query_params={'sortColumn': column, 'sortDirection': direction, 'pageSize': 4, 'page': page}
)
course_titles = [course['course_title'][field] for course in courses]
self.assertEqual(sorted(course_titles, reverse=self.sort_directions[direction]), course_titles)
@ddt.data(
{'field': 'course_team_status', 'column': 4, 'direction': 'asc'},
{'field': 'course_team_status', 'column': 4, 'direction': 'desc'},
{'field': 'internal_user_status', 'column': 5, 'direction': 'asc'},
{'field': 'internal_user_status', 'column': 5, 'direction': 'desc'},
)
@ddt.unpack
def test_ordering_by_date(self, field, column, direction):
""" Verify that ordering by date is working as expected. """
for page in (1, 2, 3):
courses = self.get_courses(
query_params={'sortColumn': column, 'sortDirection': direction, 'pageSize': 4, 'page': page}
)
course_dates = [course[field]['date'] for course in courses]
self.assertEqual(
sorted(
course_dates,
key=lambda x: datetime.strptime(x, '%m/%d/%y'),
reverse=self.sort_directions[direction]
),
course_dates
)
@ddt.data(
{'field': 'publisher_course_runs_count', 'column': 3, 'direction': 'asc'},
{'field': 'publisher_course_runs_count', 'column': 3, 'direction': 'desc'},
)
@ddt.unpack
def test_ordering_by_course_runs(self, field, column, direction):
""" Verify that ordering by course runs is working as expected. """
for page in (1, 2, 3):
courses = self.get_courses(
query_params={'sortColumn': column, 'sortDirection': direction, 'pageSize': 4, 'page': page}
)
course_runs = [course[field] for course in courses]
self.assertEqual(sorted(course_runs, reverse=self.sort_directions[direction]), course_runs)
@ddt.data(
{'query': 'course title', 'results_count': 10},
{'query': 'course 13 title ', 'results_count': 2},
{'query': 'maX title course', 'results_count': 2},
{'query': 'course title arkX', 'results_count': 2},
{'query': 'course 03/24/18 title arkX', 'results_count': 1},
{'query': 'zeroX 01/10/17 course', 'results_count': 1},
{'query': 'blah blah', 'results_count': 0},
)
@ddt.unpack
def test_filtering(self, query, results_count):
""" Verify that filtering is working as expected. """
with mock.patch('course_discovery.apps.publisher.views.COURSES_ALLOWED_PAGE_SIZES', (10,)):
courses = self.get_courses(query_params={'pageSize': 10, 'searchText': query})
self.assertEqual(len(courses), results_count)
for course in courses:
title_org_dates = '{} {} {} {}'.format(
course['course_title']['title'], course['organization_name'],
course['course_team_status']['date'], course['internal_user_status']['date'],
)
for token in query.split():
self.assertTrue(token in title_org_dates)
def test_filtering_with_multiple_dates(self):
""" Verify that filtering is working as expected. """
query = 'zeroX 01/10/17 course 02/11/17 title'
courses = self.get_courses(query_params={'pageSize': 10, 'searchText': query})
self.assertEqual(len(courses), 2)
dates = []
for course in courses:
dates.extend(
[course['course_team_status']['date'], course['internal_user_status']['date']]
)
query_without_dates = query
# verify that dates for each course record should be present on query
for date in dates:
self.assertTrue(date in query)
query_without_dates = query_without_dates.replace(date, '')
# verify that non date query keywords exist in returned courses
for course in courses:
title_and_org = '{} {}'.format(course['course_title']['title'], course['organization_name'])
for token in query_without_dates.split():
self.assertTrue(token in title_and_org)
def test_pagination_for_internal_user(self):
""" Verify that pagination works for internal user. """
with mock.patch('course_discovery.apps.publisher.views.is_publisher_admin', return_value=False):
self.user.groups.add(Group.objects.get(name=INTERNAL_USER_GROUP_NAME))
self.course_team_role = factories.CourseUserRoleFactory(
course=self.courses[0], user=self.user, role=PublisherUserRole.CourseTeam
)
self.course_team_role = factories.CourseUserRoleFactory(
course=self.courses[1], user=self.user, role=PublisherUserRole.CourseTeam
)
courses = self.get_courses()
self.assertEqual(len(courses), 2)
def test_pagination_for_user_organizations(self):
""" Verify that pagination works for user organizations. """
with mock.patch('course_discovery.apps.publisher.views.is_publisher_admin', return_value=False):
with mock.patch('course_discovery.apps.publisher.views.is_internal_user', return_value=False):
organization_extension = factories.OrganizationExtensionFactory(
organization=self.courses[0].organizations.all()[0] # zeroX
)
self.user.groups.add(organization_extension.group)
assign_perm(OrganizationExtension.VIEW_COURSE, organization_extension.group, organization_extension)
courses = self.get_courses()
self.assertEqual(len(courses), 1)
def test_context(self):
""" Verify that required data is present in context. """
with mock.patch('course_discovery.apps.publisher.views.COURSES_ALLOWED_PAGE_SIZES', COURSES_ALLOWED_PAGE_SIZES):
response = self.client.get(self.courses_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context_data['publisher_courses_url'], reverse('publisher:publisher_courses'))
self.assertEqual(response.context_data['allowed_page_sizes'], json.dumps(COURSES_ALLOWED_PAGE_SIZES))
class CourseDetailViewTests(TestCase):
""" Tests for the course detail view. """ """ Tests for the course detail view. """
def setUp(self): def setUp(self):
...@@ -2676,7 +2940,7 @@ class CourseRunEditViewTests(SiteMixin, TestCase): ...@@ -2676,7 +2940,7 @@ class CourseRunEditViewTests(SiteMixin, TestCase):
""" Verify that in case of any error transactions roll back and no object """ Verify that in case of any error transactions roll back and no object
updated in db. updated in db.
""" """
with patch.object(CourseRun, "save") as mock_method: with mock.patch.object(CourseRun, "save") as mock_method:
mock_method.side_effect = IntegrityError mock_method.side_effect = IntegrityError
response = self.client.post(self.edit_page_url, self.updated_dict) response = self.client.post(self.edit_page_url, self.updated_dict)
...@@ -2720,7 +2984,7 @@ class CourseRunEditViewTests(SiteMixin, TestCase): ...@@ -2720,7 +2984,7 @@ class CourseRunEditViewTests(SiteMixin, TestCase):
def test_logging(self): def test_logging(self):
""" Verify view logs the errors in case of errors. """ """ Verify view logs the errors in case of errors. """
with patch('django.forms.models.BaseModelForm.is_valid') as mocked_is_valid: with mock.patch('django.forms.models.BaseModelForm.is_valid') as mocked_is_valid:
mocked_is_valid.return_value = True mocked_is_valid.return_value = True
with LogCapture(publisher_views_logger.name) as log_capture: with LogCapture(publisher_views_logger.name) as log_capture:
# pop the # pop the
......
...@@ -4,12 +4,15 @@ Course publisher views. ...@@ -4,12 +4,15 @@ Course publisher views.
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce
import waffle import waffle
from django.contrib import messages from django.contrib import messages
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.db.models import Count, Q
from django.db.models.functions import Lower
from django.forms import model_to_dict from django.forms import model_to_dict
from django.http import Http404, HttpResponseRedirect, JsonResponse from django.http import Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
...@@ -22,7 +25,7 @@ from guardian.shortcuts import get_objects_for_user ...@@ -22,7 +25,7 @@ from guardian.shortcuts import get_objects_for_user
from course_discovery.apps.core.models import User from course_discovery.apps.core.models import User
from course_discovery.apps.course_metadata.models import Person from course_discovery.apps.course_metadata.models import Person
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher import emails, mixins from course_discovery.apps.publisher import emails, mixins, serializers
from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole
from course_discovery.apps.publisher.dataloader.create_courses import process_course from course_discovery.apps.publisher.dataloader.create_courses import process_course
from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing
...@@ -57,6 +60,9 @@ DEFAULT_ROLES = [ ...@@ -57,6 +60,9 @@ DEFAULT_ROLES = [
COURSE_ROLES = [PublisherUserRole.CourseTeam] COURSE_ROLES = [PublisherUserRole.CourseTeam]
COURSE_ROLES.extend(DEFAULT_ROLES) COURSE_ROLES.extend(DEFAULT_ROLES)
COURSES_DEFAULT_PAGE_SIZE = 25
COURSES_ALLOWED_PAGE_SIZES = (25, 50, 100)
class Dashboard(mixins.LoginRequiredMixin, ListView): class Dashboard(mixins.LoginRequiredMixin, ListView):
""" Create Course View.""" """ Create Course View."""
...@@ -796,6 +802,7 @@ class ToggleEmailNotification(mixins.LoginRequiredMixin, View): ...@@ -796,6 +802,7 @@ class ToggleEmailNotification(mixins.LoginRequiredMixin, View):
class CourseListView(mixins.LoginRequiredMixin, ListView): class CourseListView(mixins.LoginRequiredMixin, ListView):
""" Course List View.""" """ Course List View."""
template_name = 'publisher/courses.html' template_name = 'publisher/courses.html'
paginate_by = COURSES_DEFAULT_PAGE_SIZE
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
...@@ -817,6 +824,9 @@ class CourseListView(mixins.LoginRequiredMixin, ListView): ...@@ -817,6 +824,9 @@ class CourseListView(mixins.LoginRequiredMixin, ListView):
).values_list('organization') ).values_list('organization')
courses = courses.filter(organizations__in=organizations) courses = courses.filter(organizations__in=organizations)
courses = self.do_ordering(courses)
courses = self.do_filtering(courses)
return courses return courses
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
...@@ -824,8 +834,167 @@ class CourseListView(mixins.LoginRequiredMixin, ListView): ...@@ -824,8 +834,167 @@ class CourseListView(mixins.LoginRequiredMixin, ListView):
context['publisher_hide_features_for_pilot'] = waffle.switch_is_active('publisher_hide_features_for_pilot') context['publisher_hide_features_for_pilot'] = waffle.switch_is_active('publisher_hide_features_for_pilot')
site = Site.objects.first() site = Site.objects.first()
context['site_name'] = 'edX' if 'edx' in site.name.lower() else site.name context['site_name'] = 'edX' if 'edx' in site.name.lower() else site.name
context['publisher_courses_url'] = reverse('publisher:publisher_courses')
context['allowed_page_sizes'] = json.dumps(COURSES_ALLOWED_PAGE_SIZES)
return context return context
def get_paginate_by(self, queryset):
"""
Get the number of items to paginate by.
"""
try:
page_size = int(self.request.GET.get('pageSize', COURSES_DEFAULT_PAGE_SIZE))
page_size = page_size if page_size in COURSES_ALLOWED_PAGE_SIZES else COURSES_DEFAULT_PAGE_SIZE
except ValueError:
page_size = COURSES_DEFAULT_PAGE_SIZE
return page_size
def do_ordering(self, queryset):
"""
Perform ordering on queryset
"""
# commented fields are multi-valued so ordering is not reliable becuase a single
# record can be returned multiple times. We are not doing ordering for these fields
ordering_fields = {
0: 'title',
# 1: 'organizations__key',
# 2: 'course_user_roles__user__full_name',
3: 'course_runs_count',
4: 'course_state__owner_role_modified',
5: 'course_state__owner_role_modified',
}
try:
ordering_field_index = int(self.request.GET.get('sortColumn', 0))
if ordering_field_index not in ordering_fields.keys():
raise ValueError
except ValueError:
ordering_field_index = 0
ordering_direction = self.request.GET.get('sortDirection', 'asc')
ordering_field = ordering_fields.get(ordering_field_index)
if ordering_field == 'course_runs_count':
queryset = queryset.annotate(course_runs_count=Count('publisher_course_runs'))
if ordering_direction == 'asc':
queryset = queryset.order_by(Lower(ordering_field).asc())
else:
queryset = queryset.order_by(Lower(ordering_field).desc())
return queryset
def do_filtering(self, queryset):
"""
Perform filtering on queryset
"""
filter_text = self.request.GET.get('searchText', '').strip()
if not filter_text:
return queryset
keywords, dates = self.extract_keywords_and_dates(filter_text)
query_filters = []
keywords_filter = None
for keyword in keywords:
keyword_filter = Q(title__icontains=keyword) | Q(organizations__key__icontains=keyword)
keywords_filter = (keyword_filter & keywords_filter) if bool(keywords_filter) else keyword_filter
if keywords_filter:
query_filters.append(keywords_filter)
if dates:
query_filters.append(
Q(reduce(lambda x, y: x | y, [
Q(course_state__owner_role_modified__gte=date['first']) &
Q(course_state__owner_role_modified__lt=date['second'])
for date in dates
]))
)
# Filtering is based on keywords and dates. Providing any one of them or both will filter the results.
# if both are provided then filtering happens according to the below algorithm
# << select records that contains all the keywords AND the record also contains any of the date >>
# if user enters multiple dates then we will fetch all records matching any date provided that
# those records contains all the keywords too. See the below sample records and query results
#
# {'title': 'Ops', 'org': 'arbi', 'date': '07/04/17'},
# {'title': 'Ops'", 'org': 'arbi', 'date': '07/04/17'},
# {'title': 'Ops', 'org': 'arbi', 'date': '07/10/18'},
# {'title': 'Ops', 'org': 'arbi', 'date': '07/04/17'},
# {'title': 'awesome', 'org': 'me', 'date': '07/10/18'},
#
# arbi ops << select first 4 records
# arbi 07/04/17 ops << select 1st, 2nd and 4th record
# ops 07/04/17 arbi 07/10/18 << select first 4 records
# ops 07/04/17 arbi 07/10/18 nope << no record matches -- all keywords must be present with any of the date
# 07/10/18 << select 3rd and last record
# awesome << select last record
# awesome 07/04/17 << no record matches
# distinct is used here to remove duplicate records in case a course has multiple organizations
# Note: currently this will not happen because each course has one organization only
return queryset.filter(*query_filters).distinct()
@staticmethod
def extract_keywords_and_dates(filter_text):
"""
Check each keyword in provided list of keywords and parse dates.
Arguments:
filter_text (str): input text entered by user like 'intro to python 07/04/17'
Returns:
tuple: tuple of two lists, first list contains keywords without dates and
second contains list of dicts where each dict has two date objects
"""
dates = []
keywords = []
tokens = filter_text.split()
for token in tokens:
try:
dt = datetime.strptime(token, '%m/%d/%y')
dates.append({
'first': dt,
'second': dt + timedelta(days=1),
})
except ValueError:
keywords.append(token)
return keywords, dates
def get(self, request):
"""
HTTP GET handler for publisher courses.
"""
self.object_list = self.get_queryset()
context = self.get_context_data()
context['publisher_total_courses_count'] = self.object_list.count()
courses = serializers.CourseSerializer(
context['object_list'],
many=True,
context={
'user': request.user,
'publisher_hide_features_for_pilot': context['publisher_hide_features_for_pilot'],
}
).data
if 'application/json' in request.META.get('HTTP_ACCEPT', ''):
count = self.object_list.count()
return JsonResponse({
'draw': int(self.request.GET['draw']),
'recordsTotal': count,
'recordsFiltered': count,
'courses': courses,
})
else:
context['courses'] = JsonResponse(courses, safe=False).content
return self.render_to_response(context)
class CourseRevisionView(mixins.LoginRequiredMixin, DetailView): class CourseRevisionView(mixins.LoginRequiredMixin, DetailView):
"""Course revisions view """ """Course revisions view """
......
...@@ -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: 2017-07-13 17:02-0400\n" "POT-Creation-Date: 2017-07-21 17:33+0500\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"
...@@ -875,6 +875,12 @@ msgstr "" ...@@ -875,6 +875,12 @@ msgstr ""
msgid "n/a" msgid "n/a"
msgstr "" msgstr ""
#: apps/publisher/serializers.py templates/publisher/_approval_widget.html
#: templates/publisher/course_detail/_edit_warning_popup.html
#: templates/publisher/course_run_detail/_edit_warning.html
msgid "Edit"
msgstr ""
#: apps/publisher/validators.py #: apps/publisher/validators.py
#, python-format #, python-format
msgid "" msgid ""
...@@ -1205,13 +1211,6 @@ msgid "Submitted for review" ...@@ -1205,13 +1211,6 @@ msgid "Submitted for review"
msgstr "" msgstr ""
#: templates/publisher/_approval_widget.html #: templates/publisher/_approval_widget.html
#: templates/publisher/course_detail/_edit_warning_popup.html
#: templates/publisher/course_run_detail/_edit_warning.html
#: templates/publisher/courses.html
msgid "Edit"
msgstr ""
#: templates/publisher/_approval_widget.html
msgid "ABOUT PAGE PREVIEW" msgid "ABOUT PAGE PREVIEW"
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: 2017-07-13 17:03-0400\n" "POT-Creation-Date: 2017-07-21 17:33+0500\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"
...@@ -74,6 +74,10 @@ msgstr "" ...@@ -74,6 +74,10 @@ msgstr ""
msgid "Please enter a valid URL." msgid "Please enter a valid URL."
msgstr "" msgstr ""
#: static/js/publisher/views/courses.js
msgid "No courses have been created."
msgstr ""
#: static/js/publisher/views/dashboard.js #: static/js/publisher/views/dashboard.js
msgid "" msgid ""
"You have successfully created a Studio URL ({studioLinkTag}) for " "You have successfully created a Studio URL ({studioLinkTag}) for "
......
...@@ -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: 2017-07-13 17:02-0400\n" "POT-Creation-Date: 2017-07-21 17:33+0500\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"
...@@ -1026,6 +1026,12 @@ msgstr "Ìn Révïéw sïnçé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" ...@@ -1026,6 +1026,12 @@ msgstr "Ìn Révïéw sïnçé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
msgid "n/a" msgid "n/a"
msgstr "n/ä Ⱡ'σяєм#" msgstr "n/ä Ⱡ'σяєм#"
#: apps/publisher/serializers.py templates/publisher/_approval_widget.html
#: templates/publisher/course_detail/_edit_warning_popup.html
#: templates/publisher/course_run_detail/_edit_warning.html
msgid "Edit"
msgstr "Édït Ⱡ'σяєм ι#"
#: apps/publisher/validators.py #: apps/publisher/validators.py
#, python-format #, python-format
msgid "" msgid ""
...@@ -1401,13 +1407,6 @@ msgid "Submitted for review" ...@@ -1401,13 +1407,6 @@ msgid "Submitted for review"
msgstr "Süßmïttéd för révïéw Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgstr "Süßmïttéd för révïéw Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: templates/publisher/_approval_widget.html #: templates/publisher/_approval_widget.html
#: templates/publisher/course_detail/_edit_warning_popup.html
#: templates/publisher/course_run_detail/_edit_warning.html
#: templates/publisher/courses.html
msgid "Edit"
msgstr "Édït Ⱡ'σяєм ι#"
#: templates/publisher/_approval_widget.html
msgid "ABOUT PAGE PREVIEW" msgid "ABOUT PAGE PREVIEW"
msgstr "ÀBÖÛT PÀGÉ PRÉVÌÉW Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#" msgstr "ÀBÖÛT PÀGÉ PRÉVÌÉW Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#"
......
...@@ -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: 2017-07-13 17:03-0400\n" "POT-Creation-Date: 2017-07-21 17:33+0500\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"
...@@ -83,6 +83,10 @@ msgstr "Sävé Ⱡ'σяєм ι#" ...@@ -83,6 +83,10 @@ msgstr "Sävé Ⱡ'σяєм ι#"
msgid "Please enter a valid URL." msgid "Please enter a valid URL."
msgstr "Pléäsé éntér ä välïd ÛRL. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" msgstr "Pléäsé éntér ä välïd ÛRL. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: static/js/publisher/views/courses.js
msgid "No courses have been created."
msgstr "Nö çöürsés hävé ßéén çréätéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
#: static/js/publisher/views/dashboard.js #: static/js/publisher/views/dashboard.js
msgid "" msgid ""
"You have successfully created a Studio URL ({studioLinkTag}) for " "You have successfully created a Studio URL ({studioLinkTag}) for "
......
$(document).ready(function() {
var data = $('.course-count-heading').data();
var $coursesTable = $('#dataTableCourse').DataTable({
'autoWidth': false,
'processing': true,
'serverSide': true,
'lengthMenu': $('.course-count-heading').data('publisherCoursesAllowedPageSizes'),
'deferLoading': $('.course-count-heading').data('publisherTotalCoursesCount'),
'data': $('.course-count-heading').data('publisherCourses'),
'ajax': {
'url': $('.course-count-heading').data('publisherCoursesUrl'),
'data': function(d) {
var table = $('#dataTableCourse').DataTable();
return {
draw: d.draw,
pageSize: d.length,
page: table.page.info().page + 1,
sortColumn: d.order[0].column,
sortDirection: d.order[0].dir,
searchText: d.search.value.trim()
};
},
'dataSrc': 'courses'
},
"columnDefs": [
{
"targets": 0,
"data": "course_title",
"render": function ( data, type, full, meta ) {
if (data.url) {
return '<a href="'+data.url+'">' + data.title + '</a>';
} else {
return data.title;
}
}
},
{
"targets": 1,
"data": "organization_name",
"sortable": false
},
{
"targets": 2,
"data": "project_coordinator_name",
"sortable": false
},
{
"targets": 3,
"data": "publisher_course_runs_count"
},
{
"targets": 4,
"data": "course_team_status",
"render": function ( data, type, full, meta ) {
return data.status + '<br>' + data.date;
}
},
{
"targets": 5,
"data": "internal_user_status",
"render": function ( data, type, full, meta ) {
return data.status + '<br>' + data.date;
}
},
{
"targets": 6,
"data": "edit_url",
"sortable": false,
"render": function ( data, type, full, meta ) {
if (data.url) {
return '<a href="'+data.url+ '" class="btn btn-brand btn-small btn-course-edit">' + data.title + '</a>'
} else {
return null;
}
}
}
],
'oLanguage': { 'sEmptyTable': gettext('No courses have been created.') }
});
$('div.dataTables_filter input').unbind();
$('div.dataTables_filter input').bind('keyup', function(e) {
if(e.keyCode == 13) {
$coursesTable.search( this.value ).draw();
}
});
});
{% extends 'publisher/base.html' %} {% extends 'publisher/base.html' %}
{% load i18n %} {% load i18n %}
{% load publisher_extras %} {% load static %}
{% load compress %}
{% block title %} {% block title %}
{% trans "Courses" %} {% trans "Courses" %}
{% endblock title %} {% endblock title %}
{% block page_content %} {% block page_content %}
<h2 class="hd-2 course-count-heading">{{ object_list.count }} Courses</h2> <h2 class="hd-2 course-count-heading"
data-publisher-total-courses-count="{{publisher_total_courses_count}}"
data-publisher-courses="{{courses}}"
data-publisher-courses-url="{{publisher_courses_url}}"
data-publisher-courses-allowed-page-sizes="{{allowed_page_sizes}}"
>{{publisher_total_courses_count}} Courses</h2>
<a href="{% url 'publisher:publisher_courses_new' %}" class="btn btn-brand btn-small btn-course-add"> <a href="{% url 'publisher:publisher_courses_new' %}" class="btn btn-brand btn-small btn-course-add">
{% trans "Create New Course" %} {% trans "Create New Course" %}
</a> </a>
...@@ -37,56 +43,11 @@ ...@@ -37,56 +43,11 @@
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody></tbody>
{% for course in object_list %}
<tr>
<td>
{% if publisher_hide_features_for_pilot %}
{{ course.title }}
{% else %}
<a href="{% url 'publisher:publisher_course_detail' course.id %}">
{{ course.title }}
</a>
{% endif %}
</td>
<td>
{{ course.organization_name }}
</td>
<td>
{{ course.project_coordinator.full_name }}
</td>
<td>
{{ course.publisher_course_runs.count }}
</td>
<td>
{{ course.course_state.course_team_status.status_text }}<br>
{{ course.course_state.course_team_status.date|date:'m/d/y' }}
</td>
<td>
{{ course.course_state.internal_user_status.status_text }}<br>
{{ course.course_state.internal_user_status.date|date:'m/d/y' }}
</td>
<td>
{% if not publisher_hide_features_for_pilot and course|can_edit_course:request.user %}
<a href="{% url 'publisher:publisher_courses_edit' course.id %}" class="btn btn-brand btn-small btn-course-edit">
{% trans "Edit" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script src="{% static 'js/publisher/views/courses.js' %}"></script>
$(document).ready(function() {
$('#dataTableCourse').DataTable({
"autoWidth": false,
"oLanguage": { "sEmptyTable": gettext("No courses have been created.") }
});
});
</script>
{% endblock %} {% 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