diff --git a/app/coffeescripts/bundles/legacy/context_roster_user.coffee b/app/coffeescripts/bundles/legacy/context_roster_user.coffee index ba5593dcbbc..75aaa52e291 100644 --- a/app/coffeescripts/bundles/legacy/context_roster_user.coffee +++ b/app/coffeescripts/bundles/legacy/context_roster_user.coffee @@ -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) diff --git a/app/controllers/context_controller.rb b/app/controllers/context_controller.rb index 6241a640185..f3f8231433a 100644 --- a/app/controllers/context_controller.rb +++ b/app/controllers/context_controller.rb @@ -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) diff --git a/app/controllers/enrollments_api_controller.rb b/app/controllers/enrollments_api_controller.rb index a63fc71d868..7b4032d7001 100644 --- a/app/controllers/enrollments_api_controller.rb +++ b/app/controllers/enrollments_api_controller.rb @@ -738,6 +738,26 @@ class EnrollmentsApiController < ApplicationController end end + # @API Adds last attended date to student enrollment in course + # + # @example_request + # curl https:///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. diff --git a/app/jsx/student_last_attended/StudentLastAttended.js b/app/jsx/student_last_attended/StudentLastAttended.js new file mode 100644 index 00000000000..2ec5f99fb11 --- /dev/null +++ b/app/jsx/student_last_attended/StudentLastAttended.js @@ -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 . + */ + +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 ( + + {I18n.t('Last day attended')} + + ) + } + + render() { + if (this.state.loading) { + return ( + + {this.renderTitle()} + + + + + ) + } + return ( + + {this.renderTitle()} + {I18n.t('Set Last Attended Date')}} + onDateChange={this.onDateSubmit} + messages={this.state.messages} + dateValue={ + !this.state.selectedDate || this.state.selectedDate.toString() === 'Invalid Date' + ? null + : this.state.selectedDate.toISOString() + } + validationFeedback={false} + /> + + ) + } +} diff --git a/app/jsx/student_last_attended/__tests__/StudentLastAttended.test.js b/app/jsx/student_last_attended/__tests__/StudentLastAttended.test.js new file mode 100644 index 00000000000..792a9c0ea69 --- /dev/null +++ b/app/jsx/student_last_attended/__tests__/StudentLastAttended.test.js @@ -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 . + */ + +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() + expect(tree.exists()).toBe(true) +}) + +test('renders loading component when loading', () => { + const tree = shallow() + tree.setState({ loading: true}) + const node = tree.find('Spinner') + expect(node).toHaveLength(1) +}) + +test('onDateSubmit calls correct function', () => { + const tree = mount() + const node = tree.find('Text') + expect(node.text()).toBe('Last day attended') +}) + +test('onDateSubmit sets correct error for invalid date', () => { + const tree = mount() + 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() + 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() + const instance = tree.instance(); + expect(instance.checkDateValidations('Invalid Date')[0].type).toBe('error') +}) diff --git a/app/jsx/student_last_attended/index.js b/app/jsx/student_last_attended/index.js new file mode 100644 index 00000000000..3e0b7cfd9cf --- /dev/null +++ b/app/jsx/student_last_attended/index.js @@ -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 . + */ + +import React from 'react' +import ReactDOM from 'react-dom' +import StudentLastAttended from './StudentLastAttended' + +export default function initLastAttended(rootElement, courseID, studentID, lastAttendedDate) { + ReactDOM.render( + , + rootElement + ) +} diff --git a/app/views/context/roster_user.html.erb b/app/views/context/roster_user.html.erb index 32d764e5a94..d50126f688c 100644 --- a/app/views/context/roster_user.html.erb +++ b/app/views/context/roster_user.html.erb @@ -198,6 +198,9 @@ <% end %> + <% if @context.is_a?(Course) && can_do(@context, @current_user, :manage_admin_users) %> +
+ <% end %> <%= render :partial => 'courses/link_enrollment' %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 06cb3702654..efeb480f9af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20171218182205_add_last_attended_at_to_enrollments.rb b/db/migrate/20171218182205_add_last_attended_at_to_enrollments.rb new file mode 100644 index 00000000000..6fd9887473d --- /dev/null +++ b/db/migrate/20171218182205_add_last_attended_at_to_enrollments.rb @@ -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 . +# + +class AddLastAttendedAtToEnrollments < ActiveRecord::Migration[5.0] + tag :predeploy + def change + add_column :enrollments, :last_attended_at, :datetime + end +end diff --git a/spec/controllers/context_controller_spec.rb b/spec/controllers/context_controller_spec.rb index 2a3051b8634..21adce4d365 100644 --- a/spec/controllers/context_controller_spec.rb +++ b/spec/controllers/context_controller_spec.rb @@ -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