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)
......@@ -4,12 +4,15 @@ Course publisher views.
import json
import logging
from datetime import datetime, timedelta
from functools import reduce
import waffle
from django.contrib import messages
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist
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.http import Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
......@@ -22,7 +25,7 @@ from guardian.shortcuts import get_objects_for_user
from course_discovery.apps.core.models import User
from course_discovery.apps.course_metadata.models import Person
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.dataloader.create_courses import process_course
from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing
......@@ -57,6 +60,9 @@ DEFAULT_ROLES = [
COURSE_ROLES = [PublisherUserRole.CourseTeam]
COURSE_ROLES.extend(DEFAULT_ROLES)
COURSES_DEFAULT_PAGE_SIZE = 25
COURSES_ALLOWED_PAGE_SIZES = (25, 50, 100)
class Dashboard(mixins.LoginRequiredMixin, ListView):
""" Create Course View."""
......@@ -796,6 +802,7 @@ class ToggleEmailNotification(mixins.LoginRequiredMixin, View):
class CourseListView(mixins.LoginRequiredMixin, ListView):
""" Course List View."""
template_name = 'publisher/courses.html'
paginate_by = COURSES_DEFAULT_PAGE_SIZE
def get_queryset(self):
user = self.request.user
......@@ -817,6 +824,9 @@ class CourseListView(mixins.LoginRequiredMixin, ListView):
).values_list('organization')
courses = courses.filter(organizations__in=organizations)
courses = self.do_ordering(courses)
courses = self.do_filtering(courses)
return courses
def get_context_data(self, **kwargs):
......@@ -824,8 +834,167 @@ class CourseListView(mixins.LoginRequiredMixin, ListView):
context['publisher_hide_features_for_pilot'] = waffle.switch_is_active('publisher_hide_features_for_pilot')
site = Site.objects.first()
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
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):
"""Course revisions view """
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -875,6 +875,12 @@ msgstr ""
msgid "n/a"
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
#, python-format
msgid ""
......@@ -1205,13 +1211,6 @@ msgid "Submitted for review"
msgstr ""
#: 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"
msgstr ""
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -74,6 +74,10 @@ msgstr ""
msgid "Please enter a valid URL."
msgstr ""
#: static/js/publisher/views/courses.js
msgid "No courses have been created."
msgstr ""
#: static/js/publisher/views/dashboard.js
msgid ""
"You have successfully created a Studio URL ({studioLinkTag}) for "
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -1026,6 +1026,12 @@ msgstr "Ìn Révïéw sïnçé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
msgid "n/a"
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
#, python-format
msgid ""
......@@ -1401,13 +1407,6 @@ msgid "Submitted for review"
msgstr "Süßmïttéd för révïéw Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: 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"
msgstr "ÀBÖÛT PÀGÉ PRÉVÌÉW Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#"
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -83,6 +83,10 @@ msgstr "Sävé Ⱡ'σяєм ι#"
msgid "Please enter a valid URL."
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
msgid ""
"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' %}
{% load i18n %}
{% load publisher_extras %}
{% load static %}
{% load compress %}
{% block title %}
{% trans "Courses" %}
{% endblock title %}
{% 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">
{% trans "Create New Course" %}
</a>
......@@ -37,56 +43,11 @@
<th></th>
</tr>
</thead>
<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>
<tbody></tbody>
</table>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('#dataTableCourse').DataTable({
"autoWidth": false,
"oLanguage": { "sEmptyTable": gettext("No courses have been created.") }
});
});
</script>
<script src="{% static 'js/publisher/views/courses.js' %}"></script>
{% 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