Add Ability For Teacher to Record Student Last Attended Date

fixes COMMS-610

Test Plan
* Go To People Tab in Course
* Click On Any Student Name
* Hopefully see the enrollments page unless context
is turned on
* If context is turned on then click on the student
name
* There should be a Last Attended Component after clicking
"more user details..."
* Add Last Attended Date To Student

Change-Id: I04e75759dbd48f2c8fac3fb6db825cb8ca5bc508
Reviewed-on: https://gerrit.instructure.com/135984
Tested-by: Jenkins
Reviewed-by: Felix Milea-Ciobanu <fmileaciobanu@instructure.com>
Reviewed-by: Steven Burnett <sburnett@instructure.com>
Product-Review: Matt Goodwin <mattg@instructure.com>
QA-Review: Venk Natarajan <vnatarajan@instructure.com>
This commit is contained in:
Aaron Kc Hsu 2017-12-18 10:24:35 -07:00
parent 98abf7b4a6
commit 23029c74cf
10 changed files with 299 additions and 4 deletions

View File

@ -18,12 +18,13 @@
define [
"jquery",
"i18n!context.roster_user",
"jsx/student_last_attended/index"
"jquery.ajaxJSON",
"jquery.instructure_misc_plugins",
"jquery.loadingImg",
"../../jquery.rails_flash_notifications",
"link_enrollment"
], ($, I18n) ->
], ($, I18n, initLastAttended) ->
$(document).ready ->
$(".show_user_services_checkbox").change ->
$.ajaxJSON $(".profile_url").attr("href"), "PUT",
@ -39,7 +40,6 @@ define [
$enrollment.find(".unconclude_enrollment_link_holder").hide()
$enrollment.find(".completed_at_holder").hide()
$(".conclude_enrollment_link").click (event) ->
event.preventDefault()
$(this).parents(".enrollment").confirmDelete
@ -82,4 +82,4 @@ define [
$(".more_user_information").slideDown()
$(this).hide()
initLastAttended(document.getElementById("student_last_attended__component"), ENV.COURSE_ID, ENV.USER_ID, ENV.LAST_ATTENDED_DATE)

View File

@ -263,6 +263,9 @@ class ContextController < ApplicationController
@membership = scope.first
if @membership
@enrollments = scope.to_a
js_env(COURSE_ID: @context.id,
USER_ID: user_id,
LAST_ATTENDED_DATE: @enrollments.first.last_attended_at)
log_asset_access(@membership, "roster", "roster")
end
elsif @context.is_a?(Group)

View File

@ -738,6 +738,26 @@ class EnrollmentsApiController < ApplicationController
end
end
# @API Adds last attended date to student enrollment in course
#
# @example_request
# curl https://<canvas>/api/v1/courses/:course_id/user/:user_id/last_attended"
# -X PUT => date="Thu%20Dec%2021%202017%2000:00:00%20GMT-0700%20(MST)
#
#
# @returns Enrollment
def last_attended
return unless authorized_action(@context, @current_user, [:view_all_grades, :manage_grades])
date = Time.zone.parse(params[:date])
if date
enrollments = Enrollment.where(:course_id => params[:course_id], :user_id => params[:user_id])
enrollments.update_all(last_attended_at: date)
render :json => {:date => date}
else
render :json => { :message => 'Invalid date time input' }, :status => :bad_request
end
end
protected
# Internal: Collect course enrollments that @current_user has permissions to
# read.

View File

@ -0,0 +1,145 @@
/*
* Copyright (C) 2017 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import axios from 'axios'
import I18n from 'i18n!last_attended'
import Container from '@instructure/ui-core/lib/components/Container'
import DateInput from '@instructure/ui-core/lib/components/DateInput'
import Text from '@instructure/ui-core/lib/components/Text'
import Spinner from '@instructure/ui-core/lib/components/Spinner'
import ScreenReaderContent from '@instructure/ui-core/lib/components/ScreenReaderContent'
import {showFlashError} from '../shared/FlashAlert'
export default class StudentLastAttended extends React.Component {
static propTypes = {
defaultDate: PropTypes.string,
courseID: PropTypes.number.isRequired,
studentID: PropTypes.number.isRequired
}
static defaultProps = {
defaultDate: null
}
constructor(props) {
super(props)
const currentDate = new Date(moment(this.props.defaultDate).toString())
this.state = {
selectedDate: currentDate || null,
messages: [],
loading: false
}
}
componentDidMount() {
this.createCancelToken()
}
onDateSubmit = (e, date) => {
const currentDate = new Date(date)
const messages = this.checkDateValidations(currentDate)
if (!messages.length) {
this.postDateToBackend(currentDate)
} else {
this.setState({messages})
}
}
componentWillUnMount() {
this.source.cancel()
}
// Used to allow us to cancel the axios call when posting date
createCancelToken() {
const cancelToken = axios.CancelToken
this.source = cancelToken.source()
}
checkDateValidations(date) {
if (date.toString() === 'Invalid Date') {
return [{text: I18n.t('Enter a valid date'), type: 'error'}]
} else {
return []
}
}
postDateToBackend(currentDate) {
this.setState({loading: true})
axios
.put(`/api/v1/courses/${this.props.courseID}/users/${this.props.studentID}/last_attended`, {
date: currentDate,
cancelToken: this.source.token
})
.then(() => {
this.setState({loading: false, selectedDate: currentDate})
})
.catch(() => {
this.setState({loading: false})
showFlashError(I18n.t('Failed To Change Last Attended Date'))
})
}
renderTitle() {
return (
<Container display="block" margin="small 0">
<Text margin="small 0">{I18n.t('Last day attended')}</Text>
</Container>
)
}
render() {
if (this.state.loading) {
return (
<Container display="block" margin="small x-small">
{this.renderTitle()}
<Container display="block" margin="small">
<Spinner
margin="small 0"
display="block"
title={I18n.t('Loading last attended date')}
size="small"
/>
</Container>
</Container>
)
}
return (
<Container display="block" margin="small x-small">
{this.renderTitle()}
<DateInput
previousLabel={I18n.t('Previous Month')}
nextLabel={I18n.t('Next Month')}
label={<ScreenReaderContent>{I18n.t('Set Last Attended Date')}</ScreenReaderContent>}
onDateChange={this.onDateSubmit}
messages={this.state.messages}
dateValue={
!this.state.selectedDate || this.state.selectedDate.toString() === 'Invalid Date'
? null
: this.state.selectedDate.toISOString()
}
validationFeedback={false}
/>
</Container>
)
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import '@instructure/ui-themes/lib/canvas'
import React from 'react'
import { mount, shallow } from 'enzyme'
import StudentLastAttended from '../StudentLastAttended'
const defaultProps = () => ({
defaultDate: "2018-03-04T07:00:00.000Z",
courseID: 1,
studentID: 1,
})
test('renders the StudentLastAttended component', () => {
const tree = mount(<StudentLastAttended {...defaultProps()} />)
expect(tree.exists()).toBe(true)
})
test('renders loading component when loading', () => {
const tree = shallow(<StudentLastAttended {...defaultProps()} />)
tree.setState({ loading: true})
const node = tree.find('Spinner')
expect(node).toHaveLength(1)
})
test('onDateSubmit calls correct function', () => {
const tree = mount(<StudentLastAttended {...defaultProps()} />)
const node = tree.find('Text')
expect(node.text()).toBe('Last day attended')
})
test('onDateSubmit sets correct error for invalid date', () => {
const tree = mount(<StudentLastAttended {...defaultProps()} />)
const instance = tree.instance();
instance.onDateSubmit({}, 'Invalid Date')
expect(tree.state('messages')[0].type).toBe('error')
})
test('checkDateValidations returns no messages for real date', () => {
const tree = mount(<StudentLastAttended {...defaultProps()} />)
const instance = tree.instance();
expect(instance.checkDateValidations('2018-03-04T07:00:00.000Z')).toEqual([])
})
test('checkDateValidations returns error messages for invalid date', () => {
const tree = mount(<StudentLastAttended {...defaultProps()} />)
const instance = tree.instance();
expect(instance.checkDateValidations('Invalid Date')[0].type).toBe('error')
})

View File

@ -0,0 +1,32 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import ReactDOM from 'react-dom'
import StudentLastAttended from './StudentLastAttended'
export default function initLastAttended(rootElement, courseID, studentID, lastAttendedDate) {
ReactDOM.render(
<StudentLastAttended
defaultDate={lastAttendedDate}
courseID={courseID}
studentID={studentID}
/>,
rootElement
)
}

View File

@ -198,6 +198,9 @@
</tr>
<% end %>
</table>
<% if @context.is_a?(Course) && can_do(@context, @current_user, :manage_admin_users) %>
<div id="student_last_attended__component"></div>
<% end %>
</fieldset>
<%= render :partial => 'courses/link_enrollment' %>
<% end %>

View File

@ -987,6 +987,7 @@ CanvasRails::Application.routes.draw do
post 'courses/:course_id/enrollments/:id/accept', action: :accept
post 'courses/:course_id/enrollments/:id/reject', action: :reject
put 'courses/:course_id/users/:user_id/last_attended', :action => :last_attended
put 'courses/:course_id/enrollments/:id/reactivate', :action => :reactivate, :as => 'reactivate_enrollment'
delete 'courses/:course_id/enrollments/:id', action: :destroy, :as => "destroy_enrollment"

View File

@ -0,0 +1,24 @@
#
# Copyright (C) 2017 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class AddLastAttendedAtToEnrollments < ActiveRecord::Migration[5.0]
tag :predeploy
def change
add_column :enrollments, :last_attended_at, :datetime
end
end

View File

@ -178,12 +178,14 @@ describe ContextController do
expect(flash[:error]).to be_present
end
it 'limits enrollments by visibility' do
it 'limits enrollments by visibility for course default section' do
user_session(@student)
get 'roster_user', params: {:course_id => @course.id, :id => @teacher.id}
expect(response).to be_success
expect(assigns[:enrollments].map(&:course_section_id)).to match_array([@course.default_section.id, @other_section.id])
end
it 'limits enrollments by visibility for other section' do
user_session(@other_student)
get 'roster_user', params: {:course_id => @course.id, :id => @teacher.id}
expect(response).to be_success