Commit 68e66025 by Clinton Blackburn Committed by Clinton Blackburn

Updated course detail view

- Moved view into course admin app
- Updated styling

XCOM-507
parent e0c1f170
......@@ -25,6 +25,7 @@
"moment": "~2.10.3",
"underscore.string": "~3.1.1",
"backbone-super": "~1.0.4",
"backbone-route-filter": "~0.1.2"
"backbone-route-filter": "~0.1.2",
"backbone-relational": "~0.9.0"
}
}
from django.conf.urls import patterns, url
from ecommerce.core.constants import COURSE_ID_PATTERN
from ecommerce.courses import views
urlpatterns = patterns(
'',
url(r'^migrate/$', views.CourseMigrationView.as_view(), name='migrate'),
url(r'^{}/$'.format(COURSE_ID_PATTERN), views.CourseDetailView.as_view(), name='detail'),
# Declare all paths above this line to avoid dropping into the Course Admin Tool (which does its own routing)
url(r'^(.*)$', views.CourseAppView.as_view(), name='app'),
......
......@@ -8,8 +8,6 @@ from django.http import Http404, HttpResponse
from django.utils.decorators import method_decorator
from django.views.generic import View, TemplateView
from ecommerce.courses.models import Course
logger = logging.getLogger(__name__)
......@@ -26,22 +24,6 @@ class CourseAppView(StaffOnlyMixin, TemplateView):
template_name = 'courses/course_app.html'
class CourseDetailView(TemplateView):
template_name = 'courses/course_detail.html'
def get(self, request, *args, **kwargs):
if not Course.objects.filter(id=kwargs['course_id']).exists():
raise Http404
return super(CourseDetailView, self).get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(CourseDetailView, self).get_context_data()
context.update({
'course_id': kwargs['course_id']
})
return context
class CourseMigrationView(View):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:
......
......@@ -41,12 +41,14 @@ require([
$(function () {
var $app = $('#app');
// Let's start the show!
courseApp = new CourseRouter({$el: $app});
courseApp.start();
// Handle navbar clicks.
$('a.navbar-brand').on('click', navigate);
// Handle internal clicks
$app.on('click', 'a', navigate);
});
}
);
......@@ -3,6 +3,7 @@ require.config({
paths: {
'backbone': 'bower_components/backbone/backbone',
'backbone.paginator': 'bower_components/backbone.paginator/lib/backbone.paginator',
'backbone.relational': 'bower_components/backbone-relational/backbone-relational',
'backbone.route-filter': 'bower_components/backbone-route-filter/backbone-route-filter',
'backbone.super': 'bower_components/backbone-super/backbone-super/backbone-super',
'bootstrap': 'bower_components/bootstrap-sass/assets/javascripts/bootstrap',
......
define([
'backbone',
'backbone.relational',
'underscore',
'collections/product_collection',
'models/course_seat_model'
],
function (Backbone,
BackboneRelational,
_,
ProductCollection,
CourseSeatModel) {
'use strict';
return Backbone.Model.extend({
return Backbone.RelationalModel.extend({
urlRoot: '/api/v2/courses/',
defaults: {
name: ''
id: null,
name: null,
type: null
},
getProducts: function () {
if (_.isUndefined(this._products)) {
this._products = new ProductCollection();
this._products.url = this.get('products_url');
return this._products.getFirstPage({fetch: true});
}
return this._products;
},
relations: [{
type: Backbone.HasMany,
key: 'products',
relatedModel: CourseSeatModel,
includeInJSON: false,
parse: true
}],
getSeats: function () {
// Returns the seat products
return this.getProducts().filter(function (product) {
// Filter out parent products since there is no need to display or modify.
return (product instanceof CourseSeatModel) && product.get('structure') !== 'parent';
});
var seats = this.get('products').filter(function (product) {
// Filter out parent products since there is no need to display or modify.
return (product instanceof CourseSeatModel) && product.get('structure') !== 'parent';
}),
seatTypes = _.map(seats, function (seat) {
return seat.get('certificate_type');
});
return _.object(seatTypes, seats);
}
});
}
......
......@@ -5,6 +5,14 @@ define([
'use strict';
return ProductModel.extend({
defaults: {
certificate_type: null,
expires: null,
id_verification_required: null,
price: null,
product_class: 'Seat'
},
getSeatType: function () {
switch (this.get('certificate_type')) {
case 'verified':
......
define([
'backbone'
'backbone',
'backbone.relational',
'moment',
'underscore'
],
function (Backbone) {
function (Backbone,
BackboneRelational,
moment,
_) {
'use strict';
return Backbone.Model.extend({
return Backbone.RelationalModel.extend({
urlRoot: '/api/v2/products/',
nestedAttributes: ['certificate_type', 'id_verification_required', 'course_key'],
initialize: function () {
// Expose the nested attribute values as top-level attributes on the model
this.get('attribute_values').forEach(function (av) {
this.set(av.name, av.value);
parse: function (response) {
// Un-nest the attributes
_.each(response.attribute_values, function (data) {
this.nestedAttributes.push(data.name);
response[data.name] = data.value;
}, this);
delete response.attribute_values;
// The view displaying the expires value assumes times are in the user's local timezone. We want all
// times to be displayed in UTC to avoid confusion. Strip the timezone data to workaround the UI
// deficiencies. We will restore the UTC timezone in toJSON().
if (response.expires) {
response.expires = moment.utc(response.expires).format('YYYY-MM-DDTHH:mm:ss');
}
return response;
},
toJSON: function () {
var data = _.clone(this.attributes);
data.attribute_values = [];
// Re-nest the attributes
_.each(_.uniq(this.nestedAttributes), function (attribute) {
if (this.has(attribute)) {
data.attribute_values.push({
name: attribute,
value: this.get(attribute)
});
delete data[attribute];
}
}, this);
// Restore the timezone component, and output the ISO 8601 format expected by the server.
if (data.expires) {
data.expires = moment.utc(data.expires + 'Z').format();
}
return data;
}
});
}
......
require([
'views/course_detail_view'
define([
'models/course_model',
'views/course_detail_view',
'pages/page'
],
function (CourseDetailView) {
function (Course,
CourseDetailView,
Page) {
'use strict';
new CourseDetailView();
return Page.extend({
title: function () {
return this.model.get('name') + ' - ' + gettext('View Course');
},
initialize: function (options) {
this.model = Course.findOrCreate({id: options.id});
this.view = new CourseDetailView({model: this.model});
this.listenTo(this.model, 'change sync', this.render);
this.model.fetch({data: {include_products: true}});
}
});
}
);
define([
'backbone',
'backbone.route-filter',
'backbone.super',
'pages/course_list_page'
'pages/course_list_page',
'pages/course_detail_page'
],
function (Backbone,
BackboneRouteFilter,
BackboneSuper,
CourseListPage) {
CourseListPage,
CourseDetailPage) {
'use strict';
return Backbone.Router.extend({
......@@ -33,8 +37,13 @@ define([
* refers to a jQuery Element where the pages will be rendered.
*/
initialize: function (options) {
var courseIdRegex = /([^/+]+(\/|\+)[^/+]+(\/|\+)[^/]+)/;
// This is where views will be rendered
this.$el = options.$el;
// Custom routes, requiring RegExp or other complex placeholders, should be defined here
this.route(new RegExp('^' + courseIdRegex.source + '(\/)?$'), 'show');
},
/**
......@@ -73,6 +82,17 @@ define([
var page = new CourseListPage();
this.currentView = page;
this.$el.html(page.el);
},
/**
* Display details for a single course.
* @param {String} id - ID of the course to display.
*/
show: function (id) {
var page = new CourseDetailPage({id: id});
this.currentView = page;
this.$el.html(page.el);
}
});
}
......
define([
'jquery',
'underscore.string',
'views/course_detail_view',
'models/course_model'
],
function ($,
_s,
CourseDetailView,
Course) {
'use strict';
describe('course detail view', function () {
var view,
model,
data = {
id: 'edX/DemoX/Demo_Course',
url: 'http://ecommerce.local:8002/api/v2/courses/edX/DemoX/Demo_Course/',
name: 'edX Demonstration Course',
verification_deadline: null,
type: 'verified',
products_url: 'http://ecommerce.local:8002/api/v2/courses/edX/DemoX/Demo_Course/products/',
last_edited: '2015-07-27T00:27:23Z',
products: [
{
id: 9,
url: 'http://ecommerce.local:8002/api/v2/products/9/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with honor certificate',
price: '0.00',
expires: null,
attribute_values: [
{
name: 'certificate_type',
value: 'honor'
},
{
name: 'course_key',
value: 'edX/DemoX/Demo_Course'
},
{
name: 'id_verification_required',
value: false
}
],
is_available_to_buy: true
},
{
id: 8,
url: 'http://ecommerce.local:8002/api/v2/products/8/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with verified certificate (and ID verification)',
price: '15.00',
expires: null,
attribute_values: [
{
name: 'certificate_type',
value: 'verified'
},
{
name: 'course_key',
value: 'edX/DemoX/Demo_Course'
},
{
name: 'id_verification_required',
value: true
}
],
is_available_to_buy: true
},
{
id: 7,
url: 'http://ecommerce.local:8002/api/v2/products/7/',
structure: 'parent',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course',
price: null,
expires: null,
attribute_values: [
{
name: 'course_key',
value: 'edX/DemoX/Demo_Course'
}
],
is_available_to_buy: false
}
]
};
beforeEach(function () {
model = Course.findOrCreate(data, {parse: true});
view = new CourseDetailView({model: model}).render();
});
it('should display course details', function () {
expect(view.$el.find('.course-name').text()).toEqual(model.get('name'));
expect(view.$el.find('.course-id').text()).toEqual(model.get('id'));
expect(view.$el.find('.course-type').text()).toEqual(_s.capitalize(model.get('type')));
expect(view.$el.find('.course-verification-deadline').length).toEqual(0);
});
it('should list the course seats', function () {
var $seats = view.$el.find('.course-seat'),
products = _.filter(data.products, function (product) {
return product.product_class === 'Seat' && product.structure === 'child';
});
expect($seats.length).toEqual(products.length);
// TODO Verify the rendered info matches the data
});
});
}
);
......@@ -4,7 +4,6 @@ define([
'underscore',
'underscore.string',
'moment',
'models/course_model',
'text!templates/course_detail.html',
'text!templates/_course_seat.html'
],
......@@ -13,27 +12,15 @@ define([
_,
_s,
moment,
CourseModel,
CourseDetailTemplate,
CourseSeatTemplate) {
'use strict';
return Backbone.View.extend({
el: '.course-detail-view',
className: 'course-detail-view',
initialize: function () {
var self = this,
course_id = self.$el.data('course-id');
this.course = new CourseModel({id: course_id});
this.course.fetch({
success: function (course) {
self.render();
course.getProducts().done(function () {
self.renderSeats();
});
}
});
this.listenTo(this.model, 'change', this.render);
},
getSeats: function () {
......@@ -43,7 +30,8 @@ define([
'honor', 'verified', 'no-id-professional', 'professional', 'credit'
])));
seats = _.sortBy(this.course.getSeats(), function (seat) {
seats = _.values(this.model.getSeats());
seats = _.sortBy(seats, function (seat) {
return sortObj[seat.get('certificate_type')]
});
......@@ -51,27 +39,33 @@ define([
},
render: function () {
var html, templateData;
document.title = this.course.get('name') + ' - ' + gettext('View Course');
var html,
verifcationDeadline = this.model.get('verification_deadline'),
templateData;
templateData = {
course: this.course.attributes,
courseType: _s.capitalize(this.course.get('type'))
course: this.model.attributes,
courseType: _s.capitalize(this.model.get('type')),
verificationDeadline: verifcationDeadline ? moment.utc(verifcationDeadline).format('lll z') : null
};
html = _.template(CourseDetailTemplate)(templateData);
this.$el.html(html)
this.$el.html(html);
this.renderSeats();
return this;
},
renderSeats: function () {
var html = '',
$seatHolder = $('.course-seats', this.$el);
this.getSeats().forEach(function (seat) {
_.each(this.getSeats(), function (seat) {
html += _.template(CourseSeatTemplate)({seat: seat, moment: moment});
});
$seatHolder.append(html);
$seatHolder.html(html);
}
});
}
......
......@@ -30,4 +30,3 @@
// --------------------
@import '../views/credit';
@import '../views/course_admin';
@import '../views/course_detail';
......@@ -35,3 +35,4 @@ $footer-margin: $footer-height;
// Miscellaneous
$container-bg: white;
$breadcrumb-bg: white;
......@@ -10,4 +10,41 @@
font-weight: bold;
}
}
.breadcrumb {
background-color: $breadcrumb-bg;
padding-left: 0;
margin-top: spacing-vertical(small);
}
.course-detail-view {
.page-header {
margin-top: 0;
}
.course-information {
margin-bottom: spacing-vertical(small);
.info-item {
margin-bottom: 10px;
.heading {
font-weight: bold;
}
}
}
.course-seats {
.course-seat {
margin-bottom: spacing-vertical(mid-small);
.seat-type {
margin-bottom: 5px;
border-bottom: 1px solid $page-header-border-color;
padding-bottom: 3px;
font-weight: bold;
}
}
}
}
}
.course-detail-view {
.course-information {
margin-bottom: spacing-vertical(small);
.heading {
font-weight: bold;
}
.course-id {
margin-bottom: spacing-vertical(x-small);
}
}
.course-seats {
.course-seat {
margin-bottom: spacing-vertical(small);
.seat-type {
font-weight: bold;
}
.seat-certificate-type {
}
}
}
}
<div class="row course-seat">
<div class="col-md-4">
<div class="col-sm-12">
<div class="seat-type"><%= seat.getSeatType() %></div>
<% if (seat.get('price')) { %>
<div class="seat-price"><%= gettext('Price:') + ' $' + seat.get('price') %></div>
<% } %>
</div>
<div class="col-md-4 seat-certificate-type">
<i class="fa fa-check-square-o"></i> <%= seat.getCertificateDisplayName() %>
</div>
<div class="col-md-4 seat-additional-info">
<div class="col-sm-4">
<div class="seat-price"><%= gettext('Price:') + ' $' + Number(seat.get('price')).toLocaleString() %></div>
<% var expires = seat.get('expires');
if(expires) {
print(gettext('Verification Close:') + ' ' + moment(expires).format('LLL Z'));
print(gettext('Upgrade Deadline:') + ' ' + moment.utc(expires).format('lll z'));
}
%>
</div>
<div class="col-sm-4 seat-certificate-type">
<%= seat.getCertificateDisplayName() %>
</div>
<div class="col-sm-4 seat-additional-info"></div>
</div>
<div class="container">
<ol class="breadcrumb">
<li><a href="/courses/"><%= gettext('Courses') %></a></li>
<li class="active"><%= course['name'] %></li>
</ol>
<div class="page-header">
<h1 class="hd-1 emphasized">
<%= gettext('View Course') %>
<span class="course-name"><%= course['name'] %></span>
<div class="pull-right">
<button class="btn btn-primary btn-small"><%= gettext('Edit Course') %></button>
<a class="btn btn-primary btn-small" href="/courses/<%= course['id'] %>/edit/">
<%= gettext('Edit Course') %>
</a>
</div>
</h1>
</div>
<div class="course-information">
<h3 class="hd-3"><%= gettext('Course Information') %></h3>
<h3 class="hd-3 de-emphasized"><%= gettext('Course Information') %></h3>
<div class="heading course-name"><%= course['name'] %></div>
<div class="course-id"><%= course['id'] %></div>
<div class="info-item">
<div class="heading"><%= gettext('Course ID') %></div>
<div class="course-id"><%= course['id'] %></div>
</div>
<div class="info-item">
<div class="heading"><%= gettext('Course Type') %></div>
<div class="course-type"><%= courseType %></div>
</div>
<div class="heading"><%= gettext('Course Type') %></div>
<div class="course-type"><%= courseType %></div>
<% if(verificationDeadline) { %>
<div class="info-item">
<div class="heading"><%= gettext('Verification Deadline') %></div>
<div class="course-verification-deadline"><%= verificationDeadline %></div>
</div>
<% } %>
</div>
<h3 class="hd-3"><%= gettext('Course Seats') %></h3>
<h3 class="hd-3 de-emphasized"><%= gettext('Course Seats') %></h3>
<div class="course-seats"></div>
</div>
{% extends 'edx/base.html' %}
{% load staticfiles %}
{% block content %}
<div class="course-detail-view" data-course-id="{{ course_id }}"></div>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/pages/course_detail_page.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