From f0c94a2c7e3afe67c08cae10791cbd7d843eb8ac Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 21 Nov 2012 15:17:18 -0500 Subject: [PATCH] Beginning test of details tab. --- cms/djangoapps/contentstore/views.py | 54 ++++++++- .../js/models/settings/course_detais.js | 9 +- .../js/views/settings/main_settings_view.js | 58 +++++++++- cms/templates/settings.html | 25 ++-- cms/templates/widgets/header.html | 1 + cms/urls.py | 5 +- .../models/settings/course_details.py | 108 +++++++++++++++++- 7 files changed, 229 insertions(+), 31 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 7ad349cf65..c810dc7df7 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -44,6 +44,8 @@ import sys import tarfile import time from contentstore import course_info_model +from models.settings.course_details import CourseDetails +from models.settings.course_details import CourseDetailsEncoder # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -871,9 +873,6 @@ def edit_static(request, org, course, coursename): return render_to_response('edit-static-page.html', {}) -def settings(request, org, course, coursename): - return render_to_response('settings.html', {}) - def edit_tabs(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] course_item = modulestore().get_item(location) @@ -949,13 +948,58 @@ def course_info_updates(request, org, course, provided_id=None): if request.method == 'GET': return HttpResponse(json.dumps(course_info_model.get_course_updates(location)), mimetype="application/json") elif real_method == 'POST': - # new instance (unless django makes PUT a POST): updates are coming as POST. Not sure why. return HttpResponse(json.dumps(course_info_model.update_course_updates(location, request.POST, provided_id)), mimetype="application/json") elif real_method == 'PUT': return HttpResponse(json.dumps(course_info_model.update_course_updates(location, request.POST, provided_id)), mimetype="application/json") - elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE + elif real_method == 'DELETE': return HttpResponse(json.dumps(course_info_model.delete_course_update(location, request.POST, provided_id)), mimetype="application/json") +@login_required +@ensure_csrf_cookie +def get_course_settings(request, org, course, name): + """ + Send models and views as well as html for editing the course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + + return render_to_response('settings.html', { + 'active_tab': 'settings-tab', + 'context_course': course_module, + 'course_details' : json.dumps(CourseDetails.fetch(location), cls=CourseDetailsEncoder) + }) + +@expect_json +@login_required +@ensure_csrf_cookie +def course_settings_updates(request, org, course, name, section): + """ + restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely + through json (not rendering any html) and handles section level operations rather than whole page. + + org, course: Attributes of the Location for the item to edit + section: one of details, faculty, grading, problems, discussions + """ + if section == 'details': + manager = CourseDetails + else: return + + if request.method == 'GET': + # Cannot just do a get w/o knowing the course name :-( + return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseDetailsEncoder), + mimetype="application/json") + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseDetailsEncoder), + mimetype="application/json") + + @login_required @ensure_csrf_cookie def asset_index(request, org, course, name): diff --git a/cms/static/js/models/settings/course_detais.js b/cms/static/js/models/settings/course_detais.js index 8869280792..c725ed46a2 100644 --- a/cms/static/js/models/settings/course_detais.js +++ b/cms/static/js/models/settings/course_detais.js @@ -1,8 +1,8 @@ CMS.Models.Settings.CourseDetails = Backbone.Models.extend({ defaults: { - location : null, # a Location model, required - start_date: null, # maps to 'start' - end_date: null, # maps to 'end' + location : null, // the course's Location model, required + start_date: null, // maps to 'start' + end_date: null, // maps to 'end' enrollment_start: null, enrollment_end: null, syllabus: null, @@ -19,6 +19,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Models.extend({ }, urlRoot: function() { - // TODO impl + var location = this.get('location'); + return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/details'; } }); diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 91bc7134a4..5a862dd5ef 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -3,7 +3,6 @@ CMS.Views.Settings.Main = Backbone.View.extend({ // allow navigation between the tabs events: { 'click .settings-page-menu a': "showSettingsTab", - 'blur input' : 'updateModel' }, currentTab: null, @@ -72,19 +71,50 @@ CMS.Views.Settings.Main = Backbone.View.extend({ CMS.Views.Settings.Details = Backbone.View.extend({ // Model class is CMS.Models.Settings.CourseDetails events : { - "blur input" : "updateModel" + "blur input" : "updateModel", + 'click .remove-course-syllabus' : "removeSyllabus", + 'click .new-course-syllabus' : 'assetSyllabus', + 'click .remove-course-introduction-video' : "removeVideo", + 'click .new-course-introduction-video' : 'assetVideo', }, + initialize : function() { + // TODO move the html frag to a loaded asset + this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); + // Save every change as it occurs. This may be too noisy!!! If not every change, then need sophisticated logic. + this.model.on('change', this.model.save); + }, + render: function() { if (this.model.has('start_date')) this.$el.find('#course-start-date').datepicker('setDate', this.model.get('start_date')); if (this.model.has('end_date')) this.$el.find('#course-end-date').datepicker('setDate', this.model.get('end_date')); if (this.model.has('enrollment_start')) this.$el.find('#course-enrollment-start').datepicker('setDate', this.model.get('enrollment_start')); if (this.model.has('enrollment_end')) this.$el.find('#course-enrollment-end').datepicker('setDate', this.model.get('enrollment_end')); + + if (this.model.has('syllabus')) { + this.$el.find('.current-course-syllabus .doc-filename').html( + this.fileAnchorTemplate({ + fullpath : this.model.get('syllabus'), + filename: 'syllabus'})); + this.$el.find('.remove-course-syllabus').show(); + } + else this.$el.find('.remove-course-syllabus').hide(); + + if (this.model.has('overview')) + this.$el.find('#course-overview').text(this.model.get('overview')); + + if (this.model.has('intro_video')) { + this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.get('intro_video')); + this.$el.find('.remove-course-introduction-video').show(); + } + else this.$el.find('.remove-course-introduction-video').hide(); }, + updateModel: function(event) { // figure out which field switch (event.currentTarget.id) { case 'course-start-date': - this.model.set('start_date', $(event.currentTarget).datepicker('getDate')); + var val = $(event.currentTarget).datepicker('getDate'); + this.model.set('start_date', val); break; case 'course-end-date': this.model.set('end_date', $(event.currentTarget).datepicker('getDate')); @@ -96,10 +126,28 @@ CMS.Views.Settings.Details = Backbone.View.extend({ this.model.set('enrollment_end', $(event.currentTarget).datepicker('getDate')); break; + case 'course-overview': + this.model.set('overview', $(event.currentTarget).text()); + break; + default: break; } - // save the updated model - this.model.save(); + }, + + removeSyllabus: function() { + if (this.model.has('syllabus')) this.model.set({'syllabus': null}); + }, + + assetSyllabus : function() { + // TODO implement + }, + + removeVideo: function() { + if (this.model.has('intro_video')) this.model.set({'intro_video': null}); + }, + + assetVideo : function() { + // TODO implement } }); \ No newline at end of file diff --git a/cms/templates/settings.html b/cms/templates/settings.html index cb1bebeb89..fc58674847 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -28,8 +28,14 @@ el: $('.main-wrapper'), model : settingsModel) }); - $('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); + $('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); + + $(":input, textarea").focus(function() { + $("label[for='" + this.id + "']").addClass("is-focused"); + }).blur(function() { + $("label").removeClass("is-focused"); + }); editor.render(); }); @@ -42,11 +48,6 @@ var gradeThresholds; var GRADES = ['A', 'B', 'C', 'D', 'E']; - $(" :input, textarea").focus(function() { - $("label[for='" + this.id + "']").addClass("is-focused"); - }).blur(function() { - $("label").removeClass("is-focused"); - }); (function() { $body = $('body'); @@ -146,7 +147,7 @@
- This is used in your course URL, and cannot be changed + This is used in your course URL, and cannot be changed
@@ -225,8 +226,8 @@
-
- 📄CS184x_syllabus.pdf + @@ -255,7 +256,7 @@
- Introductions, prerequisites, FAQs that are used on your course summary page + Introductions, prerequisites, FAQs that are used on your course summary page
@@ -264,8 +265,8 @@
-
- + diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 73ce3f0604..f65becb9c7 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -14,6 +14,7 @@
  • Tabs
  • Assets
  • Users
  • +
  • Settings
  • Import
  • % endif diff --git a/cms/urls.py b/cms/urls.py index 7fc2a152a3..9cfae48865 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -36,14 +36,13 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'), url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)$', 'contentstore.views.course_settings', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/course_settings/updates/(?P.*)$', 'contentstore.views.course_settings_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)/section/(?P
    [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', name='static_pages'), url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), - url(r'^settings/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.settings', name='settings'), # temporary landing page for a course url(r'^edge/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.landing', name='landing'), diff --git a/common/djangoapps/models/settings/course_details.py b/common/djangoapps/models/settings/course_details.py index c2b78a0a7b..ed28733283 100644 --- a/common/djangoapps/models/settings/course_details.py +++ b/common/djangoapps/models/settings/course_details.py @@ -1,4 +1,10 @@ -### A basic question is whether to break the details into schedule, intro, requirements, and misc sub objects +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError +import json +from json.encoder import JSONEncoder + class CourseDetails: def __init__(self, location): self.course_location = location # a Location obj @@ -16,9 +22,107 @@ class CourseDetails: """ Fetch the course details for the given course from persistence and return a CourseDetails model. """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + course = cls(course_location) - # TODO implement + descriptor = modulestore('direct').get_item(course_location) + + ## DEBUG verify that this is a ClassDescriptor object + if not isinstance(descriptor, CourseDescriptor): + print("oops, not the expected type: ", descriptor) + + ## FIXME convert these from time.struct_time objects to something the client wants + course.start_date = descriptor.start + course.end_date = descriptor.end + course.enrollment_start = descriptor.enrollment_start + course.enrollment_end = descriptor.enrollment_end + + temploc = course_location._replace(category='about', name='syllabus') + try: + course.syllabus = modulestore('direct').get_item(temploc).definition['data'] + except ItemNotFoundError: + pass + + temploc = course_location._replace(name='overview') + try: + course.overview = modulestore('direct').get_item(temploc).definition['data'] + except ItemNotFoundError: + pass + + temploc = course_location._replace(name='effort') + try: + course.effort = modulestore('direct').get_item(temploc).definition['data'] + except ItemNotFoundError: + pass + + temploc = course_location._replace(name='video') + try: + course.intro_video = modulestore('direct').get_item(temploc).definition['data'] + except ItemNotFoundError: + pass return course + @classmethod + def update_from_json(cls, jsonval): + """ + Decode the json into CourseDetails and save any changed attrs to the db + """ + jsondict = json.loads(jsonval) + + ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore + course_location = jsondict['course_location'] + ## Will probably want to cache the inflight courses because every blur generates an update + descriptor = modulestore('direct').get_item(course_location) + + dirty = False + + ## FIXME do more accurate comparison (convert to time? or convert persisted from time) + if (jsondict['start_date'] != descriptor.start): + dirty = True + descriptor.start = jsondict['start_date'] + + if (jsondict['end_date'] != descriptor.start): + dirty = True + descriptor.end = jsondict['end_date'] + + if (jsondict['enrollment_start'] != descriptor.enrollment_start): + dirty = True + descriptor.enrollment_start = jsondict['enrollment_start'] + + if (jsondict['enrollment_end'] != descriptor.enrollment_end): + dirty = True + descriptor.enrollment_end = jsondict['enrollment_end'] + + if dirty: + modulestore('direct').update_item(course_location, descriptor.definition['data']) + + # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed + # to make faster, could compare against db or could have client send over a list of which fields changed. + temploc = course_location._replace(category='about', name='syllabus') + modulestore('direct').update_item(temploc, jsondict['syllabus']) + + temploc = course_location._replace(name='overview') + modulestore('direct').update_item(temploc, jsondict['overview']) + + temploc = course_location._replace(name='effort') + modulestore('direct').update_item(temploc, jsondict['effort']) + + temploc = course_location._replace(name='video') + modulestore('direct').update_item(temploc, jsondict['intro_video']) + + + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm + # it persisted correctly + return CourseDetails.fetch(course_location) + +class CourseDetailsEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, CourseDetails): + return obj.__dict__ + elif isinstance(obj, Location): + return obj.dict() + else: + return JSONEncoder.default(self, obj) \ No newline at end of file