From b68e5e209b7b3f71acf4ab48ef4bbf0dc2d00a12 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Sat, 12 Sep 2015 22:01:59 -0700 Subject: [PATCH] Update course details page --- app/styles/courses/course-details.sass | 139 ++++++++- app/templates/courses/course-details.jade | 265 ++++++++++++++++-- app/views/clans/ClanDetailsView.coffee | 1 - app/views/courses/CourseDetailsView.coffee | 197 +++++++++++-- server/courses/course_instance_handler.coffee | 30 +- 5 files changed, 593 insertions(+), 39 deletions(-) diff --git a/app/styles/courses/course-details.sass b/app/styles/courses/course-details.sass index 8c7b6af91..4a61c3453 100644 --- a/app/styles/courses/course-details.sass +++ b/app/styles/courses/course-details.sass @@ -1,7 +1,142 @@ #course-details-view - .edit-description-input + .invite-emails + width: 50% + + .progress-cell + padding: 2px + padding-bottom: 10px + + .progress-popup-container + display: none + position: absolute + padding: 10px + border: 1px solid black + z-index: 3 + background-color: blanchedalmond + font-size: 10pt + + .progress-concept-cell + display: inline-block + white-space: nowrap + font-size: 12px + line-height: 12px + border: 1px solid gray + margin: 0px + padding: 2px + + .progress-concept-cell-complete + background-color: lightgray + + .progress-concept-cell-started + background-color: lightgreen + + .progress-concept-completion-container + font-size: 10pt + + .progress-concepts-label + color: #317EAC + font-size: 12pt + font-weight: bold + margin-top: 8px + margin-bottom: 4px + + .progress-concept-summary + width: 100% + background-color: white + cursor: default + display: inline-block + white-space: nowrap + font-size: 9pt + font-weight: normal + border: 1px solid gray + margin: 0px + padding: 2px + background-color: white + + .progress-concepts-container width: 100% - .edit-name-input + .progress-condensed-cell + width: 100% + + .progress-header + margin-right: 14px + cursor: pointer + + .progress-key + cursor: default + display: inline-block + white-space: nowrap + font-size: 12px + line-height: 12px + font-weight: normal + border: 1px solid gray + margin: 0px + padding: 2px + + .progress-key-complete + background-color: lightgray + + .progress-key-started + background-color: lightgreen + + .progress-expand-checkbox + margin-left: 14px + + .progress-expand-label + font-weight: normal + font-size: 14px + + .progress-level-cell + display: inline-block + white-space: nowrap + font-size: 12px + line-height: 12px + border: 1px solid gray + margin: 0px + padding: 2px + + .progress-level-cell-complete + cursor: pointer + background-color: lightgray + + .progress-level-cell-started + cursor: pointer + background-color: lightgreen + + .progess-levels-label + color: #317EAC + font-size: 12pt + font-weight: bold + margin-top: 8px + + .progress-member-cell + width: 150px + + .progress-member-header + cursor: pointer + display: inline-block + padding: 2px + + .progress-stats-container + font-size: 12pt + td + padding-right: 8px + + .progress-summary-container + font-size: 14pt + + #settingsModal .modal-dialog + background-color: white + font-size: 14pt + + .settings-description-input + width: 100% + + .settings-language-select + width: 200px + display: inline + + .settings-name-input width: 50% diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 490d26575..b6c42b524 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -9,23 +9,254 @@ block content a.spl(href='mailto:team@codecombat.com') team@codecombat.com div(style='border-bottom: 1px solid black;') - h1(style='text-align: center;') Course - if course - div= course.get('name') - div= course.get('description') - div= course.get('campaignID') - div= course.get('concepts') + if me.isAnonymous() + h1 TODO: logged out + else if !course || !courseInstance + h1 Loading... else - div No course found. + h1= courseInstance.get('name') || 'Unnamed Class' + small.spl (#{course.get('name')}) - h1(style='text-align: center;') Class - if courseInstance p - div= courseInstance.get('name') || 'Class Name' - div= courseInstance.get('description') - div= courseInstance.get('courseID') - div= courseInstance.get('ownerID') - div= courseInstance.get('members') - div= courseInstance.get('prepaidID') - else - p No classes found. + if courseInstance.get('description') + each line in courseInstance.get('description').split('\n') + div= line + if adminMode && courseInstance + +settings-dialog + p + button.btn.btn-xs(data-toggle='modal', data-target='#settingsModal') edit class settings + + div.well.well-sm(role='tabpanel') + ul.nav.nav-pills(role='tablist') + li.active(role='presentation') + a(href='#progress', aria-controls='progress', role='tab', data-toggle='tab') Class Progress + if adminMode + li(role='presentation') + a(href='#invite', aria-controls='invite', role='tab', data-toggle='tab') Add Students + li(role='presentation') + a(href='#levels', aria-controls='levels', role='tab', data-toggle='tab') Levels + .tab-content + .tab-pane.active#progress(role='tabpanel') + +progress-tab + if adminMode + .tab-pane#invite(role='tabpanel') + +invite-tab + .tab-pane#levels(role='tabpanel') + +levels-tab + +mixin progress-tab + .container-fluid.progress-summary-container + .row + .col-md-6 + +progress-summary-stats + .col-md-6 + +progress-summary-concepts + +progress-members + +mixin progress-summary-stats + h3 Statistics + table.progress-stats-container + tr + td Total students: + td + if courseInstance + div #{courseInstance.get('members').length} + tr + td Average level play time: + td TODO + tr + td Total play time: + td TODO + tr + td Average levels completed: + td TODO + tr + td Total levels completed: + td TODO + tr + td Furthest level completed: + td TODO + +mixin progress-summary-concepts + h3 Concepts Covered + if course && courseInstance && conceptsCompleted + table.progress-concepts-container + each concept in course.get('concepts') + - var conceptCompletion = Math.round(parseFloat(conceptsCompleted[concept]) / courseInstance.get('members').length * 100) + if isNaN(conceptCompletion) + - conceptCompletion = 0 + tr + td.progress-concept-completion-container + span.progress-concept-summary(style="width:#{conceptCompletion}%;") + span.spr(data-i18n="concepts." + concept) + span - #{conceptCompletion}% + +mixin progress-members + h3 Students + table.table.table-condensed + thead + tr + th + span.progress-member-header.spr Name + if memberSort === 'nameAsc' + span.progress-member-header.glyphicon.glyphicon-chevron-up + else if memberSort === 'nameDesc' + span.progress-member-header.glyphicon.glyphicon-chevron-down + th + span.progress-header.spr Progress + if memberSort === 'progressAsc' + span.progress-header.glyphicon.glyphicon-chevron-up + else if memberSort === 'progressDesc' + span.progress-header.glyphicon.glyphicon-chevron-down + else + span(style='padding-left:16px;') + span.progress-key.progress-key-complete complete + span.progress-key.progress-key-started started + span.progress-key not started + input.progress-expand-checkbox(type='checkbox') + span.spl.progress-expand-label Expand details + tbody + each memberID in sortedMembers + tr + td.progress-member-cell + +progress-members-individual(memberID) + td.progress-cell + if showExpandedProgress + .progress-concepts-label Concepts + +progress-members-concepts(memberID) + .progess-levels-label Levels + +progress-members-levels-expanded(memberID) + else + table + tbody + tr + td.progress-concepts-label Concepts + td.progress-condensed-cell + +progress-members-concepts(memberID) + tr + td.progess-levels-label Levels + td.progress-condensed-cell + +progress-members-levels-condensed(memberID) + +mixin progress-members-individual(memberID) + - var name = memberUserMap[memberID] ? memberUserMap[memberID].get('name') : 'Anoner' + a(href="/user/#{memberID}")= name || 'Anoner' + div TODO: levels completed + div TODO: total time played + div TODO: last played + +mixin progress-members-concepts(memberID) + if course && userLevelStateMap[memberID] + each concept in course.get('concepts') + if userConceptStateMap[memberID][concept] === 'complete' + span.spr.progress-concept-cell.progress-concept-cell-complete(data-i18n="concepts." + concept) + else if userConceptStateMap[memberID][concept] === 'started' + span.spr.progress-concept-cell.progress-concept-cell-started(data-i18n="concepts." + concept) + else if showExpandedProgress + span.spr.progress-concept-cell.progress-concept-cell-not-started(data-i18n="concepts." + concept) + +mixin progress-members-levels-expanded(memberID) + if campaign && userLevelStateMap[memberID] + - var i = 0 + each level, levelID in campaign.get('levels') + if userLevelStateMap[memberID][levelID] === 'complete' + span.progress-level-cell.progress-level-cell-complete #{i + 1} + span.spl= level.name.replace('Course: ', '') + +progress-members-popup-completed(i, level) + else if userLevelStateMap[memberID][levelID] === 'started' + span.progress-level-cell.progress-level-cell-started #{i + 1} #{level.name.replace('Course: ', '')} + +progress-members-popup-started(i, level) + else + span.progress-level-cell #{i + 1} #{level.name.replace('Course: ', '')} + - i++ + +mixin progress-members-levels-condensed(memberID) + if campaign && userLevelStateMap[memberID] + - var numLevels = Object.keys(campaign.get('levels')).length + - var levelCellWidth = 100.00 + if numLevels > 0 + levelCellWidth = 100.00 / numLevels + - var i = 0 + each level, levelID in campaign.get('levels') + if userLevelStateMap[memberID][levelID] === 'complete' + span.progress-level-cell.progress-level-cell-complete(style="width:#{levelCellWidth}%;") #{i + 1} + +progress-members-popup-completed(i, level) + else if userLevelStateMap[memberID][levelID] === 'started' + span.progress-level-cell.progress-level-cell-started(style="width:#{levelCellWidth}%;") #{i + 1} + +progress-members-popup-started(i, level) + else + break + - i++ + +mixin progress-members-popup-completed(i, level) + .progress-popup-container + h3 #{i + 1}. #{level.name.replace('Course: ', '')} + p TODO: Time to solve + p TODO: Completed on + strong Click to view solution. + +mixin progress-members-popup-started(i, level) + .progress-popup-container + h3 #{i + 1}. #{level.name.replace('Course: ', '')} + p TODO: last played on + strong Click to view solution. + +mixin invite-tab + p Invite students to join this class. + p TODO: Student unlock code + p TODO: Class capacity + textarea.invite-emails(rows=3, placeholder="Enter student emails to invite, one per line") + div(style='margin-top:10px;') + button.btn.btn-success.btn-invite Send Invites + +mixin levels-tab + table.table.table-striped.table-condensed + thead + tr + th + th Status + th Level + th Concepts + tbody + if campaign + each level, levelID in campaign.get('levels') + tr + td + button.btn.btn-success.btn-play-level(data-level-slug=level.slug) Play + td + if userLevelStateMap[me.id] + div= userLevelStateMap[me.id][levelID] + td= level.name.replace('Course: ', '') + td + if levelConceptMap[levelID] + each concept in course.get('concepts') + if levelConceptMap[levelID][concept] + span.spr.progress-level-cell.progress-level-cell-not-started(data-i18n="concepts." + concept) + +mixin settings-dialog + .modal#settingsModal + .modal-dialog + .modal-header + button.close(data-dismiss='modal') + span × + h3.modal-title Edit Class Settings + .modal-body + p + strong Title + p + input.settings-name-input(type='text', value="#{courseInstance.get('name') || ''}") + p + strong Description + p + textarea.settings-description-input(rows=2)= courseInstance.get('description') + p Select programming languages available to the class: + p + select.form-control.settings-language-select + option(value="Python") Python + option(value="JavaScript") JavaScript + option(value="All Languages") All Languages + p + input.settings-public-progress(type='checkbox', checked) + span.spl Show student progress to everyone in the class + .modal-footer + button.btn.btn-save-settings(data-i18n="common.save_changes") diff --git a/app/views/clans/ClanDetailsView.coffee b/app/views/clans/ClanDetailsView.coffee index 16f335ac3..765609f65 100644 --- a/app/views/clans/ClanDetailsView.coffee +++ b/app/views/clans/ClanDetailsView.coffee @@ -51,7 +51,6 @@ module.exports = class ClanDetailsView extends RootView @clan = new Clan _id: @clanID @members = new CocoCollection([], { url: "/db/clan/#{@clanID}/members", model: User, comparator: 'nameLower' }) @memberAchievements = new CocoCollection([], { url: "/db/clan/#{@clanID}/member_achievements", model: EarnedAchievement, comparator:'_id' }) - # MemberSessions: only loads creatorName, levelName, codeLanguage, submittedCodeLanguage for each session @memberSessions = new CocoCollection([], { url: "/db/clan/#{@clanID}/member_sessions", model: LevelSession, comparator:'_id' }) @listenTo me, 'sync', => @render?() diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 72516ddd3..1a1d80212 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -1,8 +1,12 @@ -RootView = require 'views/core/RootView' -template = require 'templates/courses/course-details' +Campaign = require 'models/Campaign' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' CourseInstance = require 'models/CourseInstance' +LevelSession = require 'models/LevelSession' +RootView = require 'views/core/RootView' +template = require 'templates/courses/course-details' +User = require 'models/User' +utils = require 'core/utils' # TODO: logged out experience # TODO: no course instances @@ -12,27 +16,186 @@ module.exports = class CourseDetailsView extends RootView id: 'course-details-view' template: template + events: + 'change .progress-expand-checkbox': 'onCheckExpandedProgress' + 'click .btn-play-level': 'onClickPlayLevel' + 'click .btn-save-settings': 'onClickSaveSettings' + 'click .progress-member-header': 'onClickMemberHeader' + 'click .progress-header': 'onClickProgressHeader' + 'mouseenter .progress-level-cell': 'onMouseEnterPoint' + 'mouseleave .progress-level-cell': 'onMouseLeavePoint' + constructor: (options, @courseID) -> super options - @courseInstanceID = options.courseInstanceID - @course = new Course _id: @courseID - @supermodel.loadModel @course, 'course', cache: false - if @courseInstanceID - @courseInstance = new CourseInstance _id: @courseInstanceID - @supermodel.loadModel @courseInstance, 'course_instance', cache: false - else if !me.isAnonymous() - @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) - @listenToOnce @courseInstances, 'sync', @onCourseInstancesLoaded - @supermodel.loadCollection(@courseInstances, 'course_instances') + @courseInstanceID = utils.getQueryVariable('ciid', false) or options.courseInstanceID + @adminMode = me.isAdmin() + @memberSort = 'nameAsc' + unless me.isAnonymous() + @course = new Course _id: @courseID + @listenTo @course, 'sync', @onCourseSync + @supermodel.loadModel @course, 'course', cache: false getRenderData: -> context = super() - context.course = @course - context.courseInstance = @courseInstance + context.adminMode = @adminMode ? false + context.campaign = @campaign + context.conceptsCompleted = @conceptsCompleted ? {} + context.course = @course if @course?.loaded + context.courseInstance = @courseInstance if @courseInstance?.loaded + context.levelConceptMap = @levelConceptMap ? {} + context.memberSort = @memberSort + context.memberUserMap = @memberUserMap ? {} + context.showExpandedProgress = @showExpandedProgress + context.sortedMembers = @sortedMembers ? [] + context.userConceptStateMap = @userConceptStateMap ? {} + context.userLevelStateMap = @userLevelStateMap ? {} context - onCourseInstancesLoaded: -> + onCourseSync: -> + # console.log 'onCourseSync' + return if @campaign? + @campaign = new Campaign _id: @course.get('campaignID') + @listenTo @campaign, 'sync', @onCampaignSync + @supermodel.loadModel @campaign, 'campaign', cache: false + @render?() + + onCampaignSync: -> + # console.log 'onCampaignSync' + if @courseInstanceID + @loadCourseInstance(@courseInstanceID) + else if !me.isAnonymous() + @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) + @listenToOnce @courseInstances, 'sync', @onCourseInstancesSync + @supermodel.loadCollection(@courseInstances, 'course_instances') + @levelConceptMap = {} + for levelID, level of @campaign.get('levels') + @levelConceptMap[levelID] ?= {} + for concept in level.concepts + @levelConceptMap[levelID][concept] = true + @render?() + + loadCourseInstance: (courseInstanceID) -> + # console.log 'loadCourseInstance' + return if @courseInstance? + @courseInstance = new CourseInstance _id: courseInstanceID + @listenTo @courseInstance, 'sync', @onCourseInstanceSync + @supermodel.loadModel @courseInstance, 'course_instance', cache: false + + onCourseInstancesSync: -> + # console.log 'onCourseInstancesSync' if @courseInstances.models.length is 1 - @courseInstance = @courseInstances.models[0] + @loadCourseInstance(@courseInstances.models[0].id) else if @courseInstances.models.length > 0 - @courseInstance = @courseInstances.models[0] + @loadCourseInstance(@courseInstances.models[0].id) + + onCourseInstanceSync: -> + console.log 'onCourseInstanceSync', @courseInstance.get('description') + @adminMode = true if @courseInstance.get('ownerID') is me.id + @levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator:'_id' }) + @listenToOnce @levelSessions, 'sync', @onLevelSessionsSync + @supermodel.loadCollection @levelSessions, 'level_sessions', cache: false + @members = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/members", model: User, comparator: 'nameLower' }) + @listenToOnce @members, 'sync', @onMembersSync + @supermodel.loadCollection @members, 'members', cache: false + @render?() + + onLevelSessionsSync: -> + # console.log 'onLevelSessionsSync' + @userConceptStateMap = {} + @userLevelStateMap = {} + for levelSession in @levelSessions.models + userID = levelSession.get('creator') + levelID = levelSession.get('level').original + @userConceptStateMap[userID] ?= {} + @userLevelStateMap[userID] ?= {} + state = if levelSession.get('state')?.complete then 'complete' else 'started' + @userLevelStateMap[userID][levelID] = state + for concept of @levelConceptMap[levelID] + @userConceptStateMap[userID][concept] = state + @conceptsCompleted = {} + for userID, conceptStateMap of @userConceptStateMap + for concept, state of conceptStateMap + @conceptsCompleted[concept] ?= 0 + @conceptsCompleted[concept]++ + @render?() + + onMembersSync: -> + # console.log 'onMembersSync' + @memberUserMap = {} + for user in @members.models + @memberUserMap[user.id] = user + @sortMembers() + @render?() + + onCheckExpandedProgress: (e) -> + @showExpandedProgress = $('.progress-expand-checkbox').prop('checked') + # TODO: why does render reset the checkbox to be unchecked? + @render?() + $('.progress-expand-checkbox').attr('checked', @showExpandedProgress) + + onClickMemberHeader: (e) -> + @memberSort = if @memberSort is 'nameAsc' then 'nameDesc' else 'nameAsc' + @sortMembers() + @render?() + + onClickProgressHeader: (e) -> + @memberSort = if @memberSort is 'progressAsc' then 'progressDesc' else 'progressAsc' + @sortMembers() + @render?() + + onClickPlayLevel: (e) -> + levelSlug = $(e.target).data('level-slug') + Backbone.Mediator.publish 'router:navigate', { + route: "/play/level/#{levelSlug}" + viewClass: 'views/play/level/PlayLevelView' + viewArgs: [{}, levelSlug] + } + + onClickSaveSettings: (e) -> + return unless @courseInstance + if name = $('.settings-name-input').val() + @courseInstance.set('name', name) + description = $('.settings-description-input').val() + console.log 'onClickSaveSettings', description + @courseInstance.set('description', description) + @courseInstance.patch() + $('#settingsModal').modal('hide') + + onMouseEnterPoint: (e) -> + $('.level-popup-container').hide() + container = $(e.target).find('.level-popup-container').show() + margin = 20 + offset = $(e.target).offset() + scrollTop = $('#page-container').scrollTop() + height = container.outerHeight() + container.css('left', offset.left + e.offsetX) + container.css('top', offset.top + scrollTop - height - margin) + + onMouseLeavePoint: (e) -> + $(e.target).find('.level-popup-container').hide() + + sortMembers: -> + # Progress sort precedence: most completed concepts, most started concepts, most levels, name sort + return unless @campaign and @courseInstance and @memberUserMap + @sortedMembers = @courseInstance.get('members') + switch @memberSort + when "nameDesc" + @sortedMembers.sort (a, b) => @memberUserMap[b]?.get('name').localeCompare(@memberUserMap[a]?.get('name')) + when "progressAsc" + @sortedMembers.sort (a, b) => + for levelID, level of @campaign.get('levels') + if @userLevelStateMap[a][levelID] isnt 'complete' and @userLevelStateMap[b][levelID] is 'complete' + return -1 + else if @userLevelStateMap[a][levelID] is 'complete' and @userLevelStateMap[b][levelID] isnt 'complete' + return 1 + 0 + when "progressDesc" + @sortedMembers.sort (a, b) => + for levelID, level of @campaign.get('levels') + if @userLevelStateMap[a][levelID] isnt 'complete' and @userLevelStateMap[b][levelID] is 'complete' + return 1 + else if @userLevelStateMap[a][levelID] is 'complete' and @userLevelStateMap[b][levelID] isnt 'complete' + return -1 + 0 + else + @sortedMembers.sort (a, b) => @memberUserMap[a]?.get('name').localeCompare(@memberUserMap[b]?.get('name')) diff --git a/server/courses/course_instance_handler.coffee b/server/courses/course_instance_handler.coffee index 0ef6e8d2d..8530211df 100644 --- a/server/courses/course_instance_handler.coffee +++ b/server/courses/course_instance_handler.coffee @@ -3,7 +3,11 @@ Handler = require '../commons/Handler' {getCoursesPrice} = require '../../app/core/utils' Course = require './Course' CourseInstance = require './CourseInstance' +LevelSession = require '../levels/sessions/LevelSession' +LevelSessionHandler = require '../levels/sessions/level_session_handler' Prepaid = require '../prepaids/Prepaid' +User = require '../users/User' +UserHandler = require '../users/user_handler' CourseInstanceHandler = class CourseInstanceHandler extends Handler modelClass: CourseInstance @@ -14,15 +18,18 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler console.warn "Course error: #{user.get('slug')} (#{user._id}): '#{msg}'" hasAccess: (req) -> - req.method is 'GET' or req.user?.isAdmin() + req.method in @allowedMethods or req.user?.isAdmin() hasAccessToDocument: (req, document, method=null) -> - return true if _.find document?.get('members'), (a) -> a.equals(req.user?.get('_id')) + return true if document?.get('ownerID')?.equals(req.user?.get('_id')) + return true if req.method is 'GET' and _.find document?.get('members'), (a) -> a.equals(req.user?.get('_id')) req.user?.isAdmin() getByRelationship: (req, res, args...) -> relationship = args[1] return @createAPI(req, res) if relationship is 'create' + return @getLevelSessionsAPI(req, res, args[0]) if args[1] is 'level_sessions' + return @getMembersAPI(req, res, args[0]) if args[1] is 'members' super arguments... createAPI: (req, res) -> @@ -94,5 +101,24 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler Course.find {}, (err, documents) => done(err, documents) + getLevelSessionsAPI: (req, res, courseInstanceID) -> + CourseInstance.findById courseInstanceID, (err, courseInstance) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) unless courseInstance + memberIDs = _.map courseInstance.get('members') ? [], (memberID) -> memberID.toHexString?() or memberID + LevelSession.find {creator: {$in: memberIDs}}, (err, documents) => + return @sendDatabaseError(res, err) if err? + cleandocs = (LevelSessionHandler.formatEntity(req, doc) for doc in documents) + @sendSuccess(res, cleandocs) + + getMembersAPI: (req, res, courseInstanceID) -> + CourseInstance.findById courseInstanceID, (err, courseInstance) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) unless courseInstance + memberIDs = courseInstance.get('members') ? [] + User.find {_id: {$in: memberIDs}}, (err, users) => + return @sendDatabaseError(res, err) if err + cleandocs = (UserHandler.formatEntity(req, doc) for doc in users) + @sendSuccess(res, cleandocs) module.exports = new CourseInstanceHandler()