diff --git a/apps/Gruntfile.js b/apps/Gruntfile.js index 80e4f7f35faf0..2be475ec509b6 100644 --- a/apps/Gruntfile.js +++ b/apps/Gruntfile.js @@ -513,6 +513,8 @@ describe('entry tests', () => { 'pd/professional_learning_landing/index': './src/sites/studio/pages/pd/professional_learning_landing/index.js', 'pd/regional_partner_contact/new': './src/sites/studio/pages/pd/regional_partner_contact/new.js', + 'pd/international_opt_in/new': './src/sites/studio/pages/pd/international_opt_in/new.js', + 'peer_reviews/dashboard': './src/sites/studio/pages/peer_reviews/dashboard.js', 'code.org/public/teacher-dashboard/index': './src/sites/code.org/pages/public/teacher-dashboard/index.js', diff --git a/apps/i18n/common/en_us.json b/apps/i18n/common/en_us.json index 0d00aea1069d1..da9e5aed7c5f6 100644 --- a/apps/i18n/common/en_us.json +++ b/apps/i18n/common/en_us.json @@ -29,6 +29,7 @@ "allStudents": "All", "allowEditing": "Allow editing", "allowEditingInstructions": "\"Allow editing\" while students should be taking the assessment.", + "allStudents": "All students", "and": "and", "animationMode": "Animation", "announcements": "Announcements", @@ -479,6 +480,7 @@ "emptyBlockInVariable": "The variable {name} has an unfilled input.", "emptyBlocksErrorMsg": "The \"Repeat\" or \"If\" block needs to have other blocks inside it to work. Make sure the inner block fits properly inside the containing block.", "emptyExampleBlockErrorMsg": "You need at least two examples in function {functionName}. Make sure each example has a call and a result.", + "emptyFreeResponse": "No response given for this question", "emptyFunctionBlocksErrorMsg": "The function block needs to have other blocks inside it to work.", "emptyFunctionalBlock": "You have a block with an unfilled input.", "emptySurveyOverviewTable": "Because this survey is anonymous, we can only show aggregated results once there are at least 5 submissions.", @@ -493,6 +495,7 @@ "enableMakerDialogDescription": "Maker Toolkit is a feature used in the Computer Science Discoveries curriculum. See the setup page for more details:", "enableMakerDialogSetupPageLinkText": "Maker Toolkit Setup", "enablePairProgramming": "Enable Pair Programming", + "encrypted": "encrypted", "end": "end", "englishOnlyWarning": "Sorry! This stage is not available in your language. The puzzles in this stage use a mix of English words and characters that can’t be translated right now. You can move on to Stage {nextStage}.", "enterSectionCode": "Enter section code", @@ -543,6 +546,8 @@ "findLocalClassDescription": "Find a local after-school program, summer camp, or school to learn in person.", "findLocalClassButton": "Find a class", "finish": "Finish", + "formErrorsBelow": "Please correct the errors below.", + "formServerError": "Something went wrong on our end; please try again later.", "forTeachersOnly": "For Teachers Only", "fromWhen": "(From {when}):", "gdprDialogHeader": "Do you agree to using a website based in the United States?", @@ -1019,7 +1024,9 @@ "secret": "Secret", "seeAllTutorials": "See all tutorials", "selectACourse": "Select a course or unit", + "selectAnOption": "Please select an option...", "selectAssessment": "Select an assessment or survey", + "selectStudent": "Filter by student", "selectGoogleClassroom": "Select a Google Classroom", "selectCleverSection": "Select a Clever section", "selectSection": "Select Section", diff --git a/apps/src/code-studio/pd/form_components/FormController.jsx b/apps/src/code-studio/pd/form_components/FormController.jsx index 7d67345a75509..92f9fd7fe7fe2 100644 --- a/apps/src/code-studio/pd/form_components/FormController.jsx +++ b/apps/src/code-studio/pd/form_components/FormController.jsx @@ -6,6 +6,7 @@ import { FormGroup, Pagination, } from 'react-bootstrap'; +import i18n from '@cdo/locale'; const styles = { pageButtons: { @@ -211,14 +212,14 @@ export default class FormController extends React.Component { // and display the generic error header this.setState({ errors: data.responseJSON.errors.form_data, - errorHeader: "Please correct the errors below." + errorHeader: i18n.formErrorsBelow() }); } } else { // Otherwise, something unknown went wrong on the server this.setState({ globalError: true, - errorHeader: "Something went wrong on our end; please try again later." + errorHeader: i18n.formServerError() }); } this.setState({ @@ -405,7 +406,7 @@ export default class FormController extends React.Component { shouldShowSubmit() { return this.state.currentPage === this.getPageComponents().length - 1; } - static submitButtonText = "Submit"; + static submitButtonText = i18n.submit(); /** * @returns {Element} diff --git a/apps/src/code-studio/pd/international_opt_in/InternationalOptIn.jsx b/apps/src/code-studio/pd/international_opt_in/InternationalOptIn.jsx new file mode 100644 index 0000000000000..243e9f081043b --- /dev/null +++ b/apps/src/code-studio/pd/international_opt_in/InternationalOptIn.jsx @@ -0,0 +1,268 @@ +import React, {PropTypes} from 'react'; +import FormController from '../form_components/FormController'; +import FormComponent from '../form_components/FormComponent'; +import DatePicker from '../workshop_dashboard/components/date_picker'; +import moment from 'moment'; +import {DATE_FORMAT} from '../workshop_dashboard/workshopConstants'; +import { + Row, + Col, + ControlLabel, + FormGroup +} from 'react-bootstrap'; +import i18n from '@cdo/locale'; + +export default class InternationalOptIn extends FormController { + static propTypes = { + accountEmail: PropTypes.string.isRequired, + labels: PropTypes.object.isRequired + }; + + /** + * @override + */ + onSuccessfulSubmit(data) { + window.location = `/pd/international_workshop/${data.id}/thanks`; + } + + /** + * @override + */ + serializeFormData() { + const formData = super.serializeFormData(); + formData.form_data.email = this.props.accountEmail; + return formData; + } + + /** + * @override + */ + getPageComponents() { + return [ + InternationalOptInComponent + ]; + } + + /** + * @override + */ + getPageProps() { + return { + ...super.getPageProps(), + accountEmail: this.props.accountEmail, + labels: this.props.labels + }; + } +} + + +class InternationalOptInComponent extends FormComponent { + static propTypes = { + accountEmail: PropTypes.string.isRequired + }; + + handleDateChange = (date) => { + // Don't allow null. If the date is cleared, default again to today. + date = date || moment(); + super.handleChange({date: date.format(DATE_FORMAT)}); + }; + + render() { + const labels = this.props.labels; + const date = (this.props.data && this.props.data.date) ? + moment(this.props.data.date, DATE_FORMAT) : moment(); + + const lastSubjectsKey = this.props.options.subjects.slice(-1)[0]; + const textFieldMapSubjects = {[lastSubjectsKey]: "other"}; + + const lastResourcesKey = this.props.options.resources.slice(-1)[0]; + const textFieldMapResources = {[lastResourcesKey]: "other"}; + + const lastRoboticsKey = this.props.options.robotics.slice(-1)[0]; + const textFieldMapRobotics = {[lastRoboticsKey]: "other"}; + + return ( + + { + this.buildFieldGroup({ + name: 'firstName', + label: labels.firstName, + type: 'text', + required: true + }) + } + { + this.buildFieldGroup({ + name: 'firstNamePreferred', + label: labels.firstNamePreferred, + type: 'text', + required: false + }) + } + { + this.buildFieldGroup({ + name: 'lastName', + label: labels.lastName, + type: 'text', + required: true + }) + } + { + this.buildFieldGroup({ + name: 'email', + label: labels.email, + type: 'text', + value: this.props.accountEmail, + readOnly: true + }) + } + { + this.buildFieldGroup({ + name: 'emailAlternate', + label: labels.emailAlternate, + type: 'text' + }) + } + { + this.buildButtonsFromOptions({ + name: 'gender', + label: labels.gender, + type: 'radio', + required: true + }) + } + { + this.buildFieldGroup({ + name: 'schoolName', + label: labels.schoolName, + type: 'text', + required: true + }) + } + { + this.buildFieldGroup({ + name: 'schoolCity', + label: labels.schoolCity, + type: 'text', + required: true + }) + } + { + this.buildButtonsFromOptions({ + name: 'schoolCountry', + label: labels.schoolCountry, + type: 'radio', + required: true + }) + } + { + this.buildButtonsFromOptions({ + name: 'ages', + label: labels.ages, + type: 'check', + required: true + }) + } + { + this.buildButtonsWithAdditionalTextFieldsFromOptions({ + name: 'subjects', + label: labels.subjects, + type: 'check', + required: true, + textFieldMap: textFieldMapSubjects + }) + } + { + this.buildButtonsWithAdditionalTextFieldsFromOptions({ + name: 'resources', + label: labels.resources, + type: 'check', + required: false, + textFieldMap: textFieldMapResources + }) + } + { + this.buildButtonsWithAdditionalTextFieldsFromOptions({ + name: 'robotics', + label: labels.robotics, + type: 'check', + required: false, + textFieldMap: textFieldMapRobotics + }) + } + + + + + + Date + * + + + + + + + + + + { + this.buildSelectFieldGroupFromOptions({ + name: 'workshopOrganizer', + label: labels.workshopOrganizer, + required: true, + placeholder: i18n.selectAnOption() + }) + } + { + this.buildSelectFieldGroupFromOptions({ + name: 'workshopFacilitator', + label: labels.workshopFacilitator, + required: true, + placeholder: i18n.selectAnOption() + }) + } + { + this.buildSelectFieldGroupFromOptions({ + name: 'workshopCourse', + label: labels.workshopCourse, + required: true, + placeholder: i18n.selectAnOption() + }) + } + { + this.buildButtonsFromOptions({ + name: 'emailOptIn', + label: labels.emailOptIn, + type: 'radio', + required: true, + placeholder: i18n.selectAnOption() + }) + } + { + this.buildSingleCheckbox({ + name: 'legalOptIn', + label: labels.legalOptIn, + required: true + }) + } + + ); + } +} + +InternationalOptInComponent.associatedFields = [ + 'firstName', 'firstNamePreferred', 'lastName', 'email', 'emailAlternate', 'gender', + 'schoolName', 'schoolCity', 'schoolCountry', 'ages', 'subjects', 'resources', + 'robotics', 'workshopOrganizer', 'workshopFacilitator', 'workshopCourse', + 'emailOptIn', 'legalOptIn' +]; diff --git a/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/single_choice_responses.jsx b/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/single_choice_responses.jsx index c00ecf9d3edf9..27367a533216e 100644 --- a/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/single_choice_responses.jsx +++ b/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/single_choice_responses.jsx @@ -45,10 +45,10 @@ export default class SingleChoiceResponses extends React.Component { } - {possibleAnswer} + {count} - {count} + {possibleAnswer} ); diff --git a/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/text_responses.jsx b/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/text_responses.jsx index 4e3c77634a9ec..fbdc8bad4fa0c 100644 --- a/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/text_responses.jsx +++ b/apps/src/code-studio/pd/workshop_dashboard/components/survey_results/text_responses.jsx @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; import {Well} from 'react-bootstrap'; import _ from 'lodash'; +import he from 'he'; export default class TextResponses extends React.Component { static propTypes = { @@ -64,7 +65,7 @@ export default class TextResponses extends React.Component { } renderBullet(text, key) { - const trimmedText = _.trim(text); + const trimmedText = _.trim(he.decode(text)); if (trimmedText) { return (
  • diff --git a/apps/src/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/results.jsx b/apps/src/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/results.jsx index 9fe8a411e9067..efa59d623a13a 100644 --- a/apps/src/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/results.jsx +++ b/apps/src/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/results.jsx @@ -1,16 +1,8 @@ import React, {PropTypes} from 'react'; import {Tab, Tabs} from 'react-bootstrap'; -import he from 'he'; - -const styles = { - table: { - width: 'auto', - maxWidth: '50%' - }, - facilitatorResponseList: { - listStyle: 'circle' - } -}; +import SingleChoiceResponses from '../../components/survey_results/single_choice_responses'; +import TextResponses from '../../components/survey_results/text_responses'; +import _ from 'lodash'; export default class Results extends React.Component { static propTypes = { @@ -24,167 +16,68 @@ export default class Results extends React.Component { facilitatorIds: Object.keys(this.props.facilitators) }; - renderSessionResultsTable(session) { - return ( - - - - - - - - { - Object.entries(this.props.questions[session]['general']).map(([question_key, question_data], i) => { - if (['selectValue', 'none'].includes(question_data['answer_type'])) { - return ( - - - - ); - } - }) - } - -
    - This workshop
    - - { - this.computeAverageFromAnswerObject( - this.props.thisWorkshop[session]['general'][question_key], - question_data['max_value'] - ) - } -
    - ); - } - - renderSessionResultsFreeResponse(session) { - return ( -
    - { - Object.entries(this.props.questions[session]['general']).map(([question_key, question_data], i) => { - if (question_data['answer_type'] === 'text') { - return ( -
    - {question_data['text']} - { - this.props.thisWorkshop[session]['general'][question_key].map((answer, j) => ( -
  • - {he.decode(answer)} -
  • - )) - } - - ); - } - }) - } - - ); - } - - renderFacilitatorSpecificResultsTable(session) { - const hasTableResponses = Object.values(this.props.questions[session]['facilitator']).some((question) => { - return question['answer_type'] === 'selectValue'; - }); + /** + * Render results for either the facilitator specific or general questions + */ + renderResultsForSessionQuestionSection(section, questions, answers) { + return _.compact(Object.keys(questions).map((questionId, i) => { + let question = questions[questionId]; - if (hasTableResponses) { - return ( - - - - - ))} - - - - { - Object.entries(this.props.questions[session]['facilitator']).map(([question_key, question_data], i) => { - if (question_data['answer_type'] === 'selectValue') { - return ( - - - { - this.state.facilitatorIds.map((id, j) => ( - - )) - } - - ); - } - }) - } - -
    - {this.state.facilitatorIds.map((id, i) => ( - - {this.props.facilitators[id]} -
    - {question_data['text']} - - {he.decode(this.props.thisWorkshop[session]['facilitator'][question_key][id])} -
    - ); - } else { - return null; - } - } - - renderFacilitatorSpecificFreeResponses(session) { - return ( -
    - { - Object.entries(this.props.questions[session]['facilitator']).map(([question_key, question_data], i) => { - if (question_data['answer_type'] === 'text') { - return ( -
    - {question_data['text']} - { - this.state.facilitatorIds.map((id) => { - return this.renderFacilitatorSpecificBullets( - this.props.thisWorkshop[session]['facilitator'][question_key], - id - ); - }) - } -
    - ); - } - }) - } -
    - ); - } + if (!question || _.isEmpty(answers[questionId])) { + return null; + } - renderFacilitatorSpecificBullets(responses, facilitatorId) { - const hasResponses = responses && responses[facilitatorId]; - return ( -
  • - {this.props.facilitators[facilitatorId]} - -
  • - ); + if (['scale', 'singleSelect'].includes(question['answer_type'])) { + return ( + + ); + } else if (question['answer_type'] === 'text') { + return ( + + ); + } + })); } - renderFacilitatorSpecificSection(session) { + renderResultsForSession(session) { return (
    -

    - Facilitator specific questions + General Questions

    - {this.renderFacilitatorSpecificResultsTable(session)} - {this.renderFacilitatorSpecificFreeResponses(session)} + { + this.renderResultsForSessionQuestionSection( + 'general', + this.props.questions[session]['general'], + this.props.thisWorkshop[session]['general'] + ) + } + { + !_.isEmpty(this.props.questions[session]['facilitator']) && ( +
    +

    + Facilitator Specific Questions +

    + { + this.renderResultsForSessionQuestionSection( + 'facilitator', + this.props.questions[session]['facilitator'], + this.props.thisWorkshop[session]['facilitator'] + ) + } +
    + ) + }
    ); } @@ -193,33 +86,11 @@ export default class Results extends React.Component { return this.props.sessions.map((session, i) => (
    - {this.renderSessionResultsTable(session)} - {this.renderSessionResultsFreeResponse(session)} - { - this.props.thisWorkshop[session]['facilitator'] && this.renderFacilitatorSpecificSection(session) - } + {this.renderResultsForSession(session)}
    )); } - computeAverageFromAnswerObject(answerHash, maxValue) { - let sum = 0; - Object.keys(answerHash).map((key) => { - if (Number(key) > 0) { - sum += key * answerHash[key]; - } - }); - - if (sum === 0) { - return ''; - } else { - let average = sum / Object.values(answerHash).reduce((sum, x) => { - return sum + x; - }); - return `${average.toFixed(2)} / ${maxValue}`; - } - } - render() { return ( diff --git a/apps/src/lib/ui/accounts/ManageLinkedAccounts.jsx b/apps/src/lib/ui/accounts/ManageLinkedAccounts.jsx index 99b1bdc3b8a72..441e2b32641a9 100644 --- a/apps/src/lib/ui/accounts/ManageLinkedAccounts.jsx +++ b/apps/src/lib/ui/accounts/ManageLinkedAccounts.jsx @@ -10,10 +10,97 @@ const OAUTH_PROVIDERS = { GOOGLE: 'google_oauth2', FACEBOOK: 'facebook', CLEVER: 'clever', - MICROSOFT: 'microsoft', + MICROSOFT: 'windowslive', }; +export const ENCRYPTED = `*** ${i18n.encrypted()} ***`; export default class ManageLinkedAccounts extends React.Component { + static propTypes = { + userType: PropTypes.string.isRequired, + authenticationOptions: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number.isRequired, + credential_type: PropTypes.string.isRequired, + email: PropTypes.string, + hashed_email: PropTypes.string, + })).isRequired, + connect: PropTypes.func.isRequired, + disconnect: PropTypes.func.isRequired, + userHasPassword: PropTypes.bool.isRequired, + isGoogleClassroomStudent: PropTypes.bool.isRequired, + isCleverStudent: PropTypes.bool.isRequired, + }; + + getAuthenticationOption = (provider) => { + return this.props.authenticationOptions.find(option => { + return option.credential_type === provider; + }); + }; + + hasAuthOption = (provider) => { + return this.getAuthenticationOption(provider) !== undefined; + }; + + getEmailForProvider = (provider) => { + const authOption = this.getAuthenticationOption(provider); + if (authOption) { + if (this.props.userType === 'student') { + return ENCRYPTED; + } + return authOption.email; + } + }; + + toggleProvider = (provider) => { + const authOption = this.getAuthenticationOption(provider); + if (authOption) { + this.props.disconnect(authOption.id).then(_, this.onFailure); + } else { + this.props.connect(provider); + } + }; + + onFailure = (error) => { + // TODO: (madelynkasula) display error to user + console.log(error.message); + }; + + cannotDisconnectGoogle = () => { + const {isGoogleClassroomStudent} = this.props; + const cannotDisconnect = this.hasAuthOption(OAUTH_PROVIDERS.GOOGLE) ? isGoogleClassroomStudent : false; + return cannotDisconnect; + }; + + cannotDisconnectClever = () => { + const {isCleverStudent} = this.props; + const cannotDisconnect = this.hasAuthOption(OAUTH_PROVIDERS.CLEVER) ? isCleverStudent : false; + return cannotDisconnect; + }; + + cannotDisconnect = (provider) => { + const {authenticationOptions, userHasPassword} = this.props; + const otherAuthOptions = _.reject(authenticationOptions, option => option.credential_type === provider); + const otherOptionIsEmail = otherAuthOptions.length === 1 && otherAuthOptions[0].credential_type === 'email'; + + if (!this.hasAuthOption(provider)) { + // If not connected to this provider, return early + return false; + } else if (provider === OAUTH_PROVIDERS.GOOGLE && this.cannotDisconnectGoogle()) { + // Cannot disconnect from Google if student is in a Google Classroom section + return true; + } else if (provider === OAUTH_PROVIDERS.CLEVER && this.cannotDisconnectClever()) { + // Cannot disconnect from Clever if student is in a Clever section + return true; + } else if (otherAuthOptions.length === 0) { + // If it's the user's last authentication option + return true; + } else if (otherOptionIsEmail && !userHasPassword) { + // If the user's only other authentication option is an email address, a password is required to disconnect + return true; + } else { + return false; + } + }; + render() { return (
    @@ -31,27 +118,30 @@ export default class ManageLinkedAccounts extends React.Component { {}} + email={this.getEmailForProvider(OAUTH_PROVIDERS.GOOGLE)} + onClick={() => this.toggleProvider(OAUTH_PROVIDERS.GOOGLE)} + cannotDisconnect={this.cannotDisconnect(OAUTH_PROVIDERS.GOOGLE)} /> {}} + email={this.getEmailForProvider(OAUTH_PROVIDERS.MICROSOFT)} + onClick={() => this.toggleProvider(OAUTH_PROVIDERS.MICROSOFT)} + cannotDisconnect={this.cannotDisconnect(OAUTH_PROVIDERS.MICROSOFT)} /> {}} + email={this.getEmailForProvider(OAUTH_PROVIDERS.CLEVER)} + onClick={() => this.toggleProvider(OAUTH_PROVIDERS.CLEVER)} + cannotDisconnect={this.cannotDisconnect(OAUTH_PROVIDERS.CLEVER)} /> {}} - cannotDisconnect + email={this.getEmailForProvider(OAUTH_PROVIDERS.FACEBOOK)} + onClick={() => this.toggleProvider(OAUTH_PROVIDERS.FACEBOOK)} + cannotDisconnect={this.cannotDisconnect(OAUTH_PROVIDERS.FACEBOOK)} /> @@ -134,6 +224,7 @@ const styles = { paddingLeft: GUTTER, paddingRight: GUTTER, fontWeight: 'normal', + width: tableLayoutStyles.table.width / 3, }, cell: { ...tableLayoutStyles.cell, diff --git a/apps/src/lib/ui/accounts/ManageLinkedAccounts.story.jsx b/apps/src/lib/ui/accounts/ManageLinkedAccounts.story.jsx index 032262e7dbfc9..f0198392ebae4 100644 --- a/apps/src/lib/ui/accounts/ManageLinkedAccounts.story.jsx +++ b/apps/src/lib/ui/accounts/ManageLinkedAccounts.story.jsx @@ -1,6 +1,53 @@ import React from 'react'; +import {action} from '@storybook/addon-actions'; import ManageLinkedAccounts from './ManageLinkedAccounts'; +const DEFAULT_PROPS = { + userType: 'student', + authenticationOptions: [], + connect: action('connect'), + disconnect: action('disconnect'), + userHasPassword: true, + isGoogleClassroomStudent: false, + isCleverStudent: false, +}; + +const mockAuthenticationOptions = [ + {id: 1, credential_type: 'google_oauth2', email: 'google@email.com'}, + {id: 2, credential_type: 'facebook', email: 'facebook@email.com'}, + {id: 3, credential_type: 'clever', email: 'clever@email.com'}, + {id: 4, credential_type: 'windowslive', email: 'windowslive@email.com'}, +]; + export default storybook => storybook .storiesOf('ManageLinkedAccounts', module) - .add('default table', () => ()); + .addStoryTable([ + { + name: 'default table', + story: () => ( + + ) + }, + { + name: 'table for teacher with all authentication options', + story: () => ( + + ) + }, + { + name: 'table for student with all authentication options', + story: () => ( + + ) + }, + ]); diff --git a/apps/src/lib/ui/accounts/ManageLinkedAccountsController.js b/apps/src/lib/ui/accounts/ManageLinkedAccountsController.js index 39ac83f0c0ed0..e89b87baca976 100644 --- a/apps/src/lib/ui/accounts/ManageLinkedAccountsController.js +++ b/apps/src/lib/ui/accounts/ManageLinkedAccountsController.js @@ -1,12 +1,57 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import $ from 'jquery'; +import {navigateToHref} from '@cdo/apps/utils'; import ManageLinkedAccounts from './ManageLinkedAccounts'; export default class ManageLinkedAccountsController { - constructor(mountPoint) { + constructor(mountPoint, userType, authenticationOptions, userHasPassword, isGoogleClassroomStudent, isCleverStudent) { + this.mountPoint = mountPoint; + this.userType = userType; + this.authenticationOptions = authenticationOptions; + this.userHasPassword = userHasPassword; + this.isGoogleClassroomStudent = isGoogleClassroomStudent; + this.isCleverStudent = isCleverStudent; + this.renderManageLinkedAccounts(); + } + + renderManageLinkedAccounts = () => { ReactDOM.render( - , - mountPoint + , + this.mountPoint ); - } + }; + + connect = (provider) => { + navigateToHref(`/users/auth/${provider}/connect`); + }; + + disconnect = (authOptionId) => { + return new Promise((resolve, reject) => { + $.ajax({ + url: `/users/auth/${authOptionId}/disconnect`, + method: 'DELETE' + }).done(result => { + this.authenticationOptions = this.authenticationOptions.filter(option => option.id !== authOptionId); + this.renderManageLinkedAccounts(); + resolve(); + }).fail((jqXhr, _) => { + let error; + if (jqXhr.responseText) { + error = new Error(jqXhr.responseText); + } else { + error = new Error('Unexpected failure: ' + jqXhr.status); + } + reject(error); + }); + }); + }; } diff --git a/apps/src/redux/sectionDataRedux.js b/apps/src/redux/sectionDataRedux.js index d5f8252eafb22..b2d0bfabd7fa9 100644 --- a/apps/src/redux/sectionDataRedux.js +++ b/apps/src/redux/sectionDataRedux.js @@ -60,3 +60,7 @@ export default function sectionData(state=initialState, action) { export const getTotalStudentCount = (state) => { return state.sectionData.section.students.length; }; + +export const getStudentList = (state) => { + return state.sectionData.section.students; +}; diff --git a/apps/src/sites/studio/pages/devise/registrations/edit.js b/apps/src/sites/studio/pages/devise/registrations/edit.js index a785340edaeaa..5899dea6e647f 100644 --- a/apps/src/sites/studio/pages/devise/registrations/edit.js +++ b/apps/src/sites/studio/pages/devise/registrations/edit.js @@ -8,7 +8,14 @@ import getScriptData from '@cdo/apps/util/getScriptData'; // Values loaded from scriptData are always initial values, not the latest // (possibly unsaved) user-edited values on the form. const scriptData = getScriptData('edit'); -const {userAge, userType, isPasswordRequired} = scriptData; +const { + userAge, + userType, + isPasswordRequired, + authenticationOptions, + isGoogleClassroomStudent, + isCleverStudent, +} = scriptData; $(document).ready(() => { new ChangeEmailController({ @@ -31,10 +38,16 @@ $(document).ready(() => { new AddPasswordController($('#add-password-form'), addPasswordMountPoint); } - const manageLinkedAccountsMountPoint = document.getElementById('manage-linked-accounts'); if (manageLinkedAccountsMountPoint) { - new ManageLinkedAccountsController(manageLinkedAccountsMountPoint); + new ManageLinkedAccountsController( + manageLinkedAccountsMountPoint, + userType, + authenticationOptions, + isPasswordRequired, + isGoogleClassroomStudent, + isCleverStudent, + ); } initializeCreatePersonalAccountControls(); diff --git a/apps/src/sites/studio/pages/pd/international_opt_in/new.js b/apps/src/sites/studio/pages/pd/international_opt_in/new.js new file mode 100644 index 0000000000000..a43bcd2d8b30e --- /dev/null +++ b/apps/src/sites/studio/pages/pd/international_opt_in/new.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import InternationalOptIn from '@cdo/apps/code-studio/pd/international_opt_in/InternationalOptIn'; +import getScriptData from '@cdo/apps/util/getScriptData'; + +document.addEventListener("DOMContentLoaded", function (event) { + ReactDOM.render( + , document.getElementById('application-container') + ); +}); diff --git a/apps/src/templates/CodeWorkspace.jsx b/apps/src/templates/CodeWorkspace.jsx index 877aebd1aceaf..8b2f480f41d89 100644 --- a/apps/src/templates/CodeWorkspace.jsx +++ b/apps/src/templates/CodeWorkspace.jsx @@ -1,21 +1,21 @@ import $ from 'jquery'; import React, {PropTypes} from 'react'; -var Radium = require('radium'); -var connect = require('react-redux').connect; -var ProtectedStatefulDiv = require('./ProtectedStatefulDiv'); +import Radium from 'radium'; +import {connect} from 'react-redux'; +import ProtectedStatefulDiv from './ProtectedStatefulDiv'; import JsDebugger from '@cdo/apps/lib/tools/jsdebugger/JsDebugger'; import PaneHeader, {PaneSection, PaneButton} from './PaneHeader'; -var msg = require('@cdo/locale'); -var commonStyles = require('../commonStyles'); -var color = require("../util/color"); -var utils = require('@cdo/apps/utils'); +import msg from '@cdo/locale'; +import commonStyles from '../commonStyles'; +import color from "../util/color"; +import * as utils from '@cdo/apps/utils'; import {shouldUseRunModeIndicators} from '../redux/selectors'; import SettingsCog from '../lib/ui/SettingsCog'; import ShowCodeToggle from './ShowCodeToggle'; import {singleton as studioApp} from '../StudioApp'; import ProjectTemplateWorkspaceIcon from './ProjectTemplateWorkspaceIcon'; -var styles = { +const styles = { headerIcon: { fontSize: 18 }, @@ -30,8 +30,8 @@ var styles = { }, }; -var CodeWorkspace = React.createClass({ - propTypes: { +class CodeWorkspace extends React.Component { + static propTypes = { isRtl: PropTypes.bool.isRequired, editCode: PropTypes.bool.isRequired, readonlyWorkspace: PropTypes.bool.isRequired, @@ -44,9 +44,9 @@ var CodeWorkspace = React.createClass({ runModeIndicators: PropTypes.bool.isRequired, withSettingsCog: PropTypes.bool, showMakerToggle: PropTypes.bool, - }, + }; - shouldComponentUpdate: function (nextProps) { + shouldComponentUpdate(nextProps) { // This component is current semi-protected. We don't want to completely // disallow rerendering, since that would prevent us from being able to // update styles. However, we do want to prevent property changes that would @@ -63,9 +63,9 @@ var CodeWorkspace = React.createClass({ }.bind(this)); return true; - }, + } - onDebuggerSlide(debuggerHeight) { + onDebuggerSlide = (debuggerHeight) => { const textbox = this.codeTextbox.getRoot(); if (textbox.style.bottom) { $(textbox).animate( @@ -84,7 +84,7 @@ var CodeWorkspace = React.createClass({ textbox.style.bottom = debuggerHeight + 'px'; utils.fireResizeEvent(); } - }, + }; renderToolboxHeaders() { const { @@ -137,23 +137,19 @@ var CodeWorkspace = React.createClass({ {settingsCog} ]; - }, + } - onToggleShowCode(usingBlocks) { + onToggleShowCode = (usingBlocks) => { this.blockCounterEl.style.display = (usingBlocks && studioApp.enableShowBlockCount) ? 'inline-block' : 'none'; - }, + }; render() { - var props = this.props; + const props = this.props; // By default, continue to show header as focused. When runModeIndicators // is enabled, remove focus while running. - var hasFocus = true; - if (props.runModeIndicators && props.isRunning) { - hasFocus = false; - } - + const hasFocus = !(props.runModeIndicators && props.isRunning); const isRtl = this.props.isRtl; return ( @@ -197,7 +193,7 @@ var CodeWorkspace = React.createClass({
    this.blockCounterEl = el}> / - + {" " + msg.blocks()}
    @@ -219,7 +215,7 @@ var CodeWorkspace = React.createClass({ ); } -}); +} module.exports = connect(state => ({ editCode: state.pageConstants.isDroplet, diff --git a/apps/src/templates/sectionAssessments/AssessmentSelector.jsx b/apps/src/templates/sectionAssessments/AssessmentSelector.jsx index 94dfc182e989f..1d7d6b8651664 100644 --- a/apps/src/templates/sectionAssessments/AssessmentSelector.jsx +++ b/apps/src/templates/sectionAssessments/AssessmentSelector.jsx @@ -16,7 +16,7 @@ export default class AssessmentSelector extends Component { onChange(parseInt(event.target.value))} + style={dropdownStyles.dropdown} + > + + {Object.values(studentList).map((student, index) => ( + + )) + } + +
    + ); + } +} diff --git a/apps/src/templates/sectionAssessments/assessmentDataShapes.js b/apps/src/templates/sectionAssessments/assessmentDataShapes.js index 78bf9ec376342..e6561bb52de6c 100644 --- a/apps/src/templates/sectionAssessments/assessmentDataShapes.js +++ b/apps/src/templates/sectionAssessments/assessmentDataShapes.js @@ -47,9 +47,9 @@ export const studentResponsePropType = PropTypes.shape({ // Represents a single student and a set of the student's answers for // a single assessment's multiple choice questions export const studentWithResponsesPropType = PropTypes.shape({ - id: PropTypes.number.isRequired, + id: PropTypes.number, name: PropTypes.string, - studentResponses: PropTypes.arrayOf(studentResponsePropType).isRequired, + studentResponses: PropTypes.arrayOf(studentResponsePropType), }); // Represents a single multiple choice question structure diff --git a/apps/src/templates/sectionAssessments/sectionAssessmentsRedux.js b/apps/src/templates/sectionAssessments/sectionAssessmentsRedux.js index 2c486676cb356..8997b4b5ffce4 100644 --- a/apps/src/templates/sectionAssessments/sectionAssessmentsRedux.js +++ b/apps/src/templates/sectionAssessments/sectionAssessmentsRedux.js @@ -1,6 +1,8 @@ import {SET_SECTION} from '@cdo/apps/redux/sectionDataRedux'; import i18n from '@cdo/locale'; +export const ALL_STUDENT_FILTER = 0; + /** * Initial state of sectionAssessmentsRedux * The redux state matches the structure of our API calls and our views don't @@ -15,6 +17,7 @@ import i18n from '@cdo/locale'; * isLoading - boolean - indicates that requests for assessments and surveys have been * sent to the server but the client has not yet received a response * assessmentId - int - the level_group id of the assessment currently in view + * studentId - int - the studentId of the current student being filtered for. */ const initialState = { assessmentResponsesByScript: {}, @@ -22,6 +25,7 @@ const initialState = { surveysByScript: {}, isLoading: false, assessmentId: 0, + studentId: ALL_STUDENT_FILTER, }; // Question types for assessments. @@ -43,6 +47,8 @@ const MultiAnswerStatus = { const ANSWER_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; +const EMPTY_FREE_RESPONSE_MESSAGE = i18n.emptyFreeResponse(); + // Action type constants const SET_ASSESSMENT_RESPONSES = 'sectionAssessments/SET_ASSESSMENT_RESPONSES'; const SET_ASSESSMENTS_QUESTIONS = 'sectionAssessments/SET_ASSESSMENTS_QUESTIONS'; @@ -50,6 +56,7 @@ const SET_SURVEYS = 'sectionAssessments/SET_SURVEYS'; const START_LOADING_ASSESSMENTS = 'sectionAssessments/START_LOADING_ASSESSMENTS'; const FINISH_LOADING_ASSESSMENTS = 'sectionAssessments/FINISH_LOADING_ASSESSMENTS'; const SET_ASSESSMENT_ID = 'sectionAssessments/SET_ASSESSMENT_ID'; +const SET_STUDENT_ID = 'sectionAssessments/SET_STUDENT_ID'; // Action creators export const setAssessmentResponses = (scriptId, assessments) => @@ -59,6 +66,7 @@ export const setAssessmentQuestions = (scriptId, assessments) => export const startLoadingAssessments = () => ({ type: START_LOADING_ASSESSMENTS }); export const finishLoadingAssessments = () => ({ type: FINISH_LOADING_ASSESSMENTS }); export const setAssessmentId = (assessmentId) => ({ type: SET_ASSESSMENT_ID, assessmentId: assessmentId }); +export const setStudentId = (studentId) => ({ type: SET_STUDENT_ID, studentId: studentId }); export const setSurveys = (scriptId, surveys) => ({ type: SET_SURVEYS, scriptId, surveys }); export const asyncLoadAssessments = (sectionId, scriptId) => { @@ -100,6 +108,13 @@ export default function sectionAssessments(state=initialState, action) { return { ...state, assessmentId: action.assessmentId, + studentId: ALL_STUDENT_FILTER, + }; + } + if (action.type === SET_STUDENT_ID) { + return { + ...state, + studentId: action.studentId, }; } if (action.type === SET_ASSESSMENT_RESPONSES) { @@ -216,36 +231,36 @@ export const getMultipleChoiceStructureForCurrentAssessment = (state) => { export const getStudentMCResponsesForCurrentAssessment = (state) => { const studentResponses = getAssessmentResponsesForCurrentScript(state); if (!studentResponses) { - return []; + return {}; + } + const studentId = state.sectionAssessments.studentId; + const studentObject = studentResponses[studentId]; + if (!studentObject) { + return {}; } - const studentResponsesArray = Object.keys(studentResponses).map(studentId => { - studentId = (parseInt(studentId, 10)); - const studentObject = studentResponses[studentId]; - const currentAssessmentId = state.sectionAssessments.assessmentId; - const studentAssessment = studentObject.responses_by_assessment[currentAssessmentId]; + const currentAssessmentId = state.sectionAssessments.assessmentId; + const studentAssessment = studentObject.responses_by_assessment[currentAssessmentId]; - // If the student has not submitted this assessment, don't display results. - if (!studentAssessment) { - return; - } + // If the student has not submitted this assessment, don't display results. + if (!studentAssessment) { + return {}; + } - // Transform that data into what we need for this particular table, in this case - // is the structure studentAnswerDataPropType - return { - id: studentId, - name: studentObject.student_name, - studentResponses: studentAssessment.level_results.filter(answer => answer.type === QuestionType.MULTI) - .map(answer => { - return { - responses: indexesToAnswerString(answer.student_result), - isCorrect: answer.status === MultiAnswerStatus.CORRECT, - }; - }) - }; - }).filter(studentData => studentData); + // Transform that data into what we need for this particular table, in this case + // is the structure studentAnswerDataPropType + return { + id: studentId, + name: studentObject.student_name, + studentResponses: studentAssessment.level_results.filter(answer => answer.type === QuestionType.MULTI) + .map(answer => { + return { + responses: indexesToAnswerString(answer.student_result), + isCorrect: answer.status === MultiAnswerStatus.CORRECT, + }; + }) + }; - return studentResponsesArray; }; /** @@ -271,8 +286,18 @@ export const getAssessmentsFreeResponseResults = (state) => { const studentResponses = getAssessmentResponsesForCurrentScript(state); + let currentStudentsIds = Object.keys(studentResponses); + // Filter by current selected student. + if (state.sectionAssessments.studentId !== ALL_STUDENT_FILTER) { + if (!currentStudentHasResponses(state)) { + return []; + } else { + currentStudentsIds = [state.sectionAssessments.studentId]; + } + } + // For each student, look up their responses to the currently selected assessment. - Object.keys(studentResponses).forEach(studentId => { + currentStudentsIds.forEach(studentId => { studentId = (parseInt(studentId, 10)); const studentObject = studentResponses[studentId]; const currentAssessmentId = state.sectionAssessments.assessmentId; @@ -283,7 +308,7 @@ export const getAssessmentsFreeResponseResults = (state) => { questionsAndResults[index].responses.push({ id: studentId, name: studentObject.student_name, - response: response.student_result, + response: response.student_result || EMPTY_FREE_RESPONSE_MESSAGE, }); }); }); @@ -309,7 +334,7 @@ export const getSurveyFreeResponseQuestions = (state) => { questionText: question.question, questionNumber: question.question_index + 1, answers: question.results.map((response, index) => { - return {index: index, response: response.result}; + return {index: index, response: response.result || EMPTY_FREE_RESPONSE_MESSAGE}; }), }; }); @@ -407,7 +432,13 @@ export const getStudentsMCSummaryForCurrentAssessment = (state) => { ...studentResponses, }; - const studentsSummaryArray = Object.keys(allStudentsByIds).map(studentId => { + let currentStudentsIds = Object.keys(allStudentsByIds); + // Filter by current selected student. + if (state.sectionAssessments.studentId !== ALL_STUDENT_FILTER) { + currentStudentsIds = [state.sectionAssessments.studentId]; + } + + const studentsSummaryArray = currentStudentsIds.map(studentId => { studentId = (parseInt(studentId, 10)); const studentsObject = allStudentsByIds[studentId]; const currentAssessmentId = state.sectionAssessments.assessmentId; @@ -611,6 +642,13 @@ export const getExportableAssessmentData = (state) => { return responses; }; +/** + * @returns {boolean} true if current studentId has submitted responses for current script. + */ +export const currentStudentHasResponses = (state) => { + return !!getAssessmentResponsesForCurrentScript(state).hasOwnProperty(state.sectionAssessments.studentId); +}; + // Helpers /** diff --git a/apps/test/unit/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/resultsTest.js b/apps/test/unit/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/resultsTest.js index 008a286847b39..dba42a2853fc5 100644 --- a/apps/test/unit/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/resultsTest.js +++ b/apps/test/unit/code-studio/pd/workshop_dashboard/reports/local_summer_workshop_daily_survey/resultsTest.js @@ -11,11 +11,23 @@ describe("Local Summer Workshop Daily Survey Results class", () => { 'Pre Workshop': { 'general': { 'q1': {text: 'Matrix header', answer_type: 'none'}, - 'q2': {text: 'Matrix 1', answer_type: 'selectValue', max_value: '5'}, - 'q3': {text: 'Matrix 2', answer_type: 'selectValue', max_value: '5'}, - 'q4': {text: 'Scale 1', answer_type: 'selectValue', max_value: '5'}, + 'q2': {text: 'Matrix header...Matrix 1', answer_type: 'singleSelect', options: ['Poor', 'Fair', 'Good', 'Great', 'Excellent']}, + 'q3': {text: 'Matrix header...Matrix 2', answer_type: 'singleSelect', options: ['Poor', 'Fair', 'Good', 'Great', 'Excellent']}, + 'q4': {text: 'Scale 1', answer_type: 'scale', max_value: '5', options: ['1', '2', '3', '4', '5']}, + 'f1': {text: 'General, Free Response 1', answer_type: 'text'}, + 'f2': {text: 'General, Free Response 2', answer_type: 'text'} + } + }, + 'Day 1': { + 'general': { + 'q1': {text: 'Day 1 Matrix header', answer_type: 'none'}, + 'q2': {text: 'Day 1 Matrix...Matrix 1', answer_type: 'singleSelect', options: ['Poor', 'Fair', 'Good', 'Great', 'Excellent']}, + 'q3': {text: 'Scale 2', answer_type: 'scale', max_value: '5', options: ['1', '2', '3', '4', '5']}, 'f1': {text: 'Day 1, Free Response 1', answer_type: 'text'}, - 'f2': {text: 'Day 1, Free Response 2', answer_type: 'text'} + }, + 'facilitator': { + 'q1': {text: 'Day 1 Facilitator question 1', answer_type: 'text'}, + 'q2': {text: 'Day 1 Facilitator question 2', answer_type: 'text'} } } }} @@ -30,26 +42,45 @@ describe("Local Summer Workshop Daily Survey Results class", () => { 'f2': ['d', 'e', 'f'] }, 'response_count': 10 + }, + 'Day 1': { + 'general': { + 'q1': {}, + 'q2': {1: 2, 4: 5}, + 'q3': {2: 3, 3: 3, 4: 1}, + 'f1': ['g', 'h', 'i'] + }, + 'facilitator': { + 'q1': { + 1: ['j', 'k', 'l'], + 2: ['m', 'n', 'o'] + }, + 'q2': { + 1: ['p', 'q', 'r'], + 2: ['s', 't', 'u'] + } + }, + response_count: 12 } }} - sessions={['Pre Workshop']} + sessions={['Pre Workshop', 'Day 1']} facilitators={{ 1: 'Facilitator 1', 2: 'Facilitator 2' }} /> ); - expect(results.find('Tab')).to.have.length(1); - expect(results.find('table')).to.have.length(1); - expect(results.find('td').map((x) => x.text())).to.deep.equal( - [ - 'Matrix header', '', - 'Matrix 1', '3.86 / 5', - 'Matrix 2', '2.33 / 5', - 'Scale 1', '3.60 / 5', - ] - ); - expect(results.find('.well')).to.have.length(2); // 2 general responses - expect(results.find('.well li').map((x) => x.text())).to.deep.equal('abcdef'.split('')); + expect(results.find('Tab')).to.have.length(2); + let firstTab = results.find('Tab').first(); + let lastTab = results.find('Tab').last(); + + expect(firstTab.find('SingleChoiceResponses')).to.have.length(3); + expect(firstTab.find('TextResponses')).to.have.length(2); + + expect(lastTab.find('SingleChoiceResponses')).to.have.length(2); + expect(lastTab.find('TextResponses')).to.have.length(3); + + expect(firstTab.find('h3').map((x) => x.text())).to.deep.equal(['General Questions']); + expect(lastTab.find('h3').map((x) => x.text())).to.deep.equal(['General Questions', 'Facilitator Specific Questions']); }); }); diff --git a/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsControllerTest.js b/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsControllerTest.js new file mode 100644 index 0000000000000..8528744bc0bc9 --- /dev/null +++ b/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsControllerTest.js @@ -0,0 +1,134 @@ +import ReactDOM from 'react-dom'; +import sinon from 'sinon'; +import {expect, assert} from '../../../../util/configuredChai'; +import ManageLinkedAccountsController from '@cdo/apps/lib/ui/accounts/ManageLinkedAccountsController'; +import * as utils from '@cdo/apps/utils'; + +const mockAuthenticationOptions = [ + {id: 3, credential_type: 'clever'}, + {id: 1, credential_type: 'google_oauth2'}, + {id: 2, credential_type: 'facebook'}, +]; +const userHasPassword = true; +const isGoogleClassroomStudent = false; +const isCleverStudent = false; + +describe('ManageLinkedAccountsController', () => { + let controller, mockMountPoint, userType; + + beforeEach(() => { + mockMountPoint = document.createElement('div'); + document.body.appendChild(mockMountPoint); + userType = 'teacher'; + const authenticationOptions = []; + + controller = new ManageLinkedAccountsController( + mockMountPoint, + userType, + authenticationOptions, + userHasPassword, + isGoogleClassroomStudent, + isCleverStudent, + ); + + sinon.spy(ReactDOM, 'render'); + }); + + afterEach(() => { + ReactDOM.render.restore(); + document.body.removeChild(mockMountPoint); + }); + + describe('renderManageLinkedAccounts', () => { + it('renders ManageLinkedAccounts', () => { + expect(ReactDOM.render).not.to.have.been.called; + controller.renderManageLinkedAccounts(); + expect(ReactDOM.render).to.have.been.calledOnce; + }); + }); + + describe('connect', () => { + it('navigates to provider connection endpoint', () => { + const navigateToHrefStub = sinon.stub(utils, 'navigateToHref'); + controller.connect('google_oauth2'); + let arg = navigateToHrefStub.getCall(0).args[0]; + expect(navigateToHrefStub).to.have.been.calledOnce; + expect(arg).to.equal('/users/auth/google_oauth2/connect'); + utils.navigateToHref.restore(); + }); + }); + + describe('disconnect', () => { + let server; + const authOptionToBeDeleted = mockAuthenticationOptions[1]; + const authOptionId = authOptionToBeDeleted.id; + + beforeEach(() => { + server = sinon.fakeServer.create(); + }); + + afterEach(() => server.restore()); + + describe('onSuccess', () => { + beforeEach(() => { + controller = new ManageLinkedAccountsController( + mockMountPoint, + userType, + mockAuthenticationOptions, + userHasPassword, + isGoogleClassroomStudent, + isCleverStudent, + ); + + server.respondWith( + 'DELETE', + `/users/auth/${authOptionId}/disconnect`, + [204, {"Content-Type": "application/json"}, ""] + ); + }); + + it('removes the disconnected authentication option from the list', () => { + const promise = controller.disconnect(authOptionId); + server.respond(); + promise.then(() => { + expect(controller.authenticationOptions).to.not.include(authOptionToBeDeleted); + }); + }); + + it('calls renderManageLinkedAccounts', async () => { + const renderStub = sinon.stub(controller, 'renderManageLinkedAccounts'); + + expect(renderStub).to.not.have.been.called; + const promise = controller.disconnect(authOptionId); + server.respond(); + promise.then(() => { + expect(renderStub).to.have.been.calledOnce; + }); + }); + }); + + describe('onFailure', () => { + it('rejects with server response text if present', () => { + server.respondWith( + 'DELETE', + `/users/auth/${authOptionId}/disconnect`, + [400, {"Content-Type": "application/json"}, "Oh no!"] + ); + const promise = controller.disconnect(authOptionId); + server.respond(); + assert.isRejected(promise, Error, "Oh no!"); + }); + + it('rejects with default error if server response not present', () => { + server.respondWith( + 'DELETE', + `/users/auth/${authOptionId}/disconnect`, + [400, {"Content-Type": "application/json"}, ""] + ); + const promise = controller.disconnect(authOptionId); + server.respond(); + assert.isRejected(promise, Error, "Unexpected failure: 400"); + }); + }); + }); +}); diff --git a/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsTest.jsx b/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsTest.jsx new file mode 100644 index 0000000000000..15b37ca3d24a3 --- /dev/null +++ b/apps/test/unit/lib/ui/accounts/ManageLinkedAccountsTest.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import {mount} from 'enzyme'; +import sinon from 'sinon'; +import {expect} from '../../../../util/configuredChai'; +import ManageLinkedAccounts, {ENCRYPTED} from '@cdo/apps/lib/ui/accounts/ManageLinkedAccounts'; + +const DEFAULT_PROPS = { + userType: 'student', + authenticationOptions: [], + connect: () => {}, + disconnect: () => {}, + userHasPassword: true, + isGoogleClassroomStudent: false, + isCleverStudent: false, +}; + +describe('ManageLinkedAccounts', () => { + it('renders a table with oauth provider rows', () => { + const wrapper = mount( + + ); + expect(wrapper.find('table')).to.exist; + expect(wrapper.find('OauthConnection').at(0)).to.include.text('Google Account'); + expect(wrapper.find('OauthConnection').at(1)).to.include.text('Microsoft Account'); + expect(wrapper.find('OauthConnection').at(2)).to.include.text('Clever Account'); + expect(wrapper.find('OauthConnection').at(3)).to.include.text('Facebook Account'); + }); + + it('renders an empty message for unconnected authentication options', () => { + const wrapper = mount( + + ); + const googleEmailCell = wrapper.find('OauthConnection').at(0).find('td').at(1); + expect(googleEmailCell).to.have.text('Not Connected'); + }); + + it('does not render student email for authentication options', () => { + const authOptions = [ + { + id: 1, + credential_type: 'google_oauth2', + email: 'student@email.com' + } + ]; + const wrapper = mount( + + ); + const googleEmailCell = wrapper.find('OauthConnection').at(0).find('td').at(1); + expect(googleEmailCell).to.have.text(ENCRYPTED); + }); + + it('renders teacher email for authentication options', () => { + const authOptions = [ + { + id: 1, + credential_type: 'google_oauth2', + email: 'teacher@email.com' + } + ]; + const wrapper = mount( + + ); + const googleEmailCell = wrapper.find('OauthConnection').at(0).find('td').at(1); + expect(googleEmailCell).to.have.text('teacher@email.com'); + }); + + it('calls connect if authentication option is not connected', () => { + const connect = sinon.stub(); + const wrapper = mount( + + ); + wrapper.find('BootstrapButton').at(0).simulate('click'); + expect(connect).to.have.been.calledOnce; + }); + + it('calls disconnect if authentication option is connected', () => { + const authOptions = [ + {id: 1, credential_type: 'google_oauth2', email: 'student@email.com'}, + {id: 2, credential_type: 'facebook', email: 'student@email.com'} + ]; + const disconnect = sinon.stub().resolves(); + const wrapper = mount( + + ); + wrapper.find('BootstrapButton').at(0).simulate('click'); + expect(disconnect).to.have.been.calledOnce; + }); + + it('disables disconnecting from google if user is in a google classroom section', () => { + const authOptions = [{id: 1, credential_type: 'google_oauth2'}]; + const wrapper = mount( + + ); + const googleConnectButton = wrapper.find('BootstrapButton').at(0); + expect(googleConnectButton).to.have.attr('disabled'); + }); + + it('disables disconnecting from clever if user is in a clever section', () => { + const authOptions = [{id: 1, credential_type: 'clever'}]; + const wrapper = mount( + + ); + const cleverConnectButton = wrapper.find('BootstrapButton').at(2); + expect(cleverConnectButton).to.have.attr('disabled'); + }); + + it('disables disconnecting from the user\'s last authentication option', () => { + const authOptions = [{id: 1, credential_type: 'facebook'}]; + const wrapper = mount( + + ); + const facebookConnectButton = wrapper.find('BootstrapButton').at(3); + expect(facebookConnectButton).to.have.attr('disabled'); + }); + + it('disables disconnecting from the user\'s last oauth authentication option if user doesn\'t have a password', () => { + const authOptions = [{id: 1, credential_type: 'google_oauth2'}]; + const wrapper = mount( + + ); + const googleConnectButton = wrapper.find('BootstrapButton').at(0); + expect(googleConnectButton).to.have.attr('disabled'); + }); +}); diff --git a/apps/test/unit/templates/projects/SectionProjectsListTest.js b/apps/test/unit/templates/projects/SectionProjectsListTest.js index 3257a2435d6b5..f4397ab4e3f18 100644 --- a/apps/test/unit/templates/projects/SectionProjectsListTest.js +++ b/apps/test/unit/templates/projects/SectionProjectsListTest.js @@ -247,7 +247,7 @@ describe('SectionProjectsList', () => { it('shows the correct list of students in the student filter dropdown', () => { const options = root.find('option'); expect(options).to.have.length(4); - expect(options.nodes[0].innerText).to.equal('All'); + expect(options.nodes[0].innerText).to.equal('All students'); expect(options.nodes[1].innerText).to.equal('Alice'); expect(options.nodes[2].innerText).to.equal('Bob'); expect(options.nodes[3].innerText).to.equal('Charlie'); @@ -316,7 +316,7 @@ describe('SectionProjectsList', () => { // Charlie should no longer appear in the dropdown const options = root.find('option'); expect(options).to.have.length(3); - expect(options.nodes[0].innerText).to.equal('All'); + expect(options.nodes[0].innerText).to.equal('All students'); expect(options.nodes[1].innerText).to.equal('Alice'); expect(options.nodes[2].innerText).to.equal('Bob'); }); diff --git a/apps/test/unit/templates/sectionAssessments/sectionAssessmentsReduxTest.js b/apps/test/unit/templates/sectionAssessments/sectionAssessmentsReduxTest.js index 9c2332c2738d0..6003d736623e2 100644 --- a/apps/test/unit/templates/sectionAssessments/sectionAssessmentsReduxTest.js +++ b/apps/test/unit/templates/sectionAssessments/sectionAssessmentsReduxTest.js @@ -20,6 +20,7 @@ import sectionAssessments, { isCurrentAssessmentSurvey, getExportableSurveyData, getExportableAssessmentData, + setStudentId, } from '@cdo/apps/templates/sectionAssessments/sectionAssessmentsRedux'; import {setSection} from '@cdo/apps/redux/sectionDataRedux'; @@ -89,6 +90,14 @@ describe('sectionAssessmentsRedux', () => { }); }); + describe('setStudentId', () => { + it('sets the id of the current student in view', () => { + const action = setStudentId(777); + const nextState = sectionAssessments(initialState, action); + assert.deepEqual(nextState.studentId, 777); + }); + }); + describe('startLoadingAssessments', () => { it('sets isLoading to true', () => { const action = startLoadingAssessments(); @@ -211,7 +220,7 @@ describe('sectionAssessmentsRedux', () => { describe('getStudentMCResponsesForCurrentAssessment', () => { it('returns an empty array when no assessments in redux', () => { const result = getStudentMCResponsesForCurrentAssessment(rootState); - assert.deepEqual(result, []); + assert.deepEqual(result, {}); }); it('returns an array of objects of studentAnswerDataPropType', () => { @@ -219,6 +228,7 @@ describe('sectionAssessmentsRedux', () => { ...rootState, sectionAssessments: { ...rootState.sectionAssessments, + studentId: 1, assessmentId: 123, assessmentResponsesByScript: { 3: { @@ -246,7 +256,7 @@ describe('sectionAssessmentsRedux', () => { } }; const result = getStudentMCResponsesForCurrentAssessment(stateWithAssessment); - assert.deepEqual(result, [{id: 1, name: 'Saira', studentResponses: [{responses: 'D', isCorrect: false}]}]); + assert.deepEqual(result, {id: 1, name: 'Saira', studentResponses: [{responses: 'D', isCorrect: false}]}); }); }); @@ -302,6 +312,68 @@ describe('sectionAssessmentsRedux', () => { responses: [{id: 1, name: "Saira", response: "Hello world"}] }]); }); + + it('returns free response questions for selected student', () => { + const stateWithAssessment = { + ...rootState, + sectionAssessments: { + ...rootState.sectionAssessments, + assessmentId: 123, + studentId: 1, + assessmentQuestionsByScript: { + 3: { + 123: { + questions: [ + { + type: 'FreeResponse', + question_text: 'Can you say hello?', + question_index: 0, + } + ] + } + } + }, + assessmentResponsesByScript: { + 3: { + 1: { + student_name: 'Saira', + responses_by_assessment: { + 123: { + level_results: [ + { + student_result: 'Hello world', + status: '', + type: 'FreeResponse', + } + ] + } + } + }, + 2: { + student_name: 'Sarah', + responses_by_assessment: { + 123: { + level_results: [ + { + student_result: 'Hi', + status: '', + type: 'FreeResponse', + } + ] + } + } + } + } + } + } + }; + const result = getAssessmentsFreeResponseResults(stateWithAssessment); + assert.deepEqual(result, [{ + questionText: "Can you say hello?", + questionNumber: 1, + responses: [{id: 1, name: "Saira", response: "Hello world"}] + }]); + }); }); describe('getSurveyFreeResponseQuestions', () => { @@ -915,5 +987,51 @@ describe('sectionAssessmentsRedux', () => { ); }); }); + + it('returns summary data for specific student', () => { + const stateWithAssessment = { + ...rootState, + sectionData: { + section: { + students: [{ + name: "Issac", + id: 99, + }], + } + }, + sectionAssessments: { + ...rootState.sectionAssessments, + assessmentId: 123, + studentId: 99, + assessmentResponsesByScript: { + 3: { + 2: { + student_name: 'Ilulia', + responses_by_assessment: { + 123: { + multi_correct: 4, + multi_count: 10, + submitted: true, + timestamp: "2018-06-12 04:53:36 UTC", + url: "code.org", + } + } + } + } + } + } + }; + const result = getStudentsMCSummaryForCurrentAssessment(stateWithAssessment); + assert.deepEqual(result, + [ + { + id: 99, + name: "Issac", + isSubmitted: false, + submissionTimeStamp: "Not started" + }, + ] + ); + }); }); }); diff --git a/bin/cron/restart_high_memory_frontend_services b/bin/cron/restart_high_memory_frontend_services new file mode 100755 index 0000000000000..71b6d3803f1e7 --- /dev/null +++ b/bin/cron/restart_high_memory_frontend_services @@ -0,0 +1,81 @@ +#!/usr/bin/env ruby + +require_relative '../../dashboard/config/environment' +require_relative '../../lib/cdo/only_one' +require 'aws-sdk' +require 'cdo/chat_client' +require 'fileutils' +require 'sshkit' + +def main + ChatClient.message 'infra-production', 'Beginning to find and restart front end services with high memory utilization' + begin + ec2_resource = Aws::EC2::Resource.new + cloudwatch_resource = Aws::CloudWatch::Resource.new + frontend_servers_to_restart = Array.new + + production_frontends = ec2_resource.instances( + { + filters: [ + { + name: 'tag:environment', + values: [ + 'production' + ] + }, + { + name: 'tag:aws:cloudformation:logical-id', + values: [ + 'Frontends' + ] + }, + { + name: 'instance-state-name', + values: [ + 'running' + ] + } + ] + } + ) + ChatClient.message 'infra-production', "Found #{production_frontends.count} production front end webservers" + production_frontends.each do |instance| + memory_utilization = cloudwatch_resource.metric('System/Linux', 'MemoryUtilization').get_statistics( + { + dimensions: [ + { + name: 'InstanceId', + value: instance.instance_id, + } + ], + start_time: Time.now - 5.minutes, + end_time: Time.now, + period: 5.minutes, + statistics: ['Average'], + unit: 'Percent' + } + # Use safe navigation operator because a new instance may not have reported this metric yet. + ).datapoints[0]&.average + uptime_days = (Time.now - instance.launch_time) / (60 * 60 * 24) + ChatClient.message 'infra-production', "Instance ID - #{instance.instance_id} / Memory Utilization - #{memory_utilization&.round}% / Uptime (Days) - #{uptime_days.round(2)}" + frontend_servers_to_restart << instance.private_dns_name if uptime_days > 1 + end + + delay_per_group = 10.minutes + group_size = 1 + restart_command = 'sudo service dashboard upgrade && sudo service pegasus upgrade' + + # Copy SSH configuration needed to "fanout" to each front end server. + FileUtils.cp('~/.ssh/config_fanout', '~/.ssh/config') + + SSHKit::Backend::Netssh.configure {|ssh| ssh.ssh_options = {paranoid: false}} + SSHKit::Coordinator.new(frontend_servers_to_restart).each(in: :sequence, wait: delay_per_group, limit: group_size) do + ChatClient.message 'infra-production', capture(restart_command, raise_on_non_zero_exit: false) + end + ChatClient.message 'infra-production', 'Done finding and restarting front end services with high memory utilization' + rescue StandardError => error + ChatClient.message 'infra-production', "Error finding/restarting frond end services with high memory utilization - #{error.message}" + end +end + +main if only_one_running?(__FILE__) diff --git a/cookbooks/Berksfile.lock b/cookbooks/Berksfile.lock index ec0996630cfc0..3d657c384a1fe 100644 --- a/cookbooks/Berksfile.lock +++ b/cookbooks/Berksfile.lock @@ -70,7 +70,7 @@ GRAPH cdo-analytics (0.0.0) apt (~> 2.6.0) ark (>= 0.0.0) - cdo-apps (0.2.299) + cdo-apps (0.2.300) apt (>= 0.0.0) build-essential (>= 0.0.0) cdo-analytics (>= 0.0.0) diff --git a/cookbooks/cdo-apps/metadata.rb b/cookbooks/cdo-apps/metadata.rb index 1772af96ed3f8..ea502db3dfa63 100644 --- a/cookbooks/cdo-apps/metadata.rb +++ b/cookbooks/cdo-apps/metadata.rb @@ -4,7 +4,7 @@ license 'All rights reserved' description 'Installs/Configures cdo-apps' long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) -version '0.2.299' +version '0.2.300' depends 'apt' depends 'build-essential' diff --git a/cookbooks/cdo-apps/templates/default/crontab.erb b/cookbooks/cdo-apps/templates/default/crontab.erb index 77f05e7336019..d93cae2686cc1 100644 --- a/cookbooks/cdo-apps/templates/default/crontab.erb +++ b/cookbooks/cdo-apps/templates/default/crontab.erb @@ -99,6 +99,10 @@ cronjob at:'0 10 * * *', do:deploy_dir('bin', 'cron', 'regional_partner_reporting') cronjob at:'1 7 * * 6', do:deploy_dir('bin', 'cron', 'cleanup_workshop_attendance_codes') + # 4AM UTC is 8 or 9PM PT, a low traffic time when engineers are still awake to monitor any issues that occur + # during the automated restarts. + cronjob at:'00 4 * * *', do:deploy_dir('bin', 'cron', 'restart_high_memory_frontend_services') + # RDS backup window is 08:50-09:20, so by 11:50 backups should definitely be ready cronjob at:'50 11 * * *', do:deploy_dir('bin', 'cron', 'push_latest_rds_backup_to_secondary_account') end diff --git a/dashboard/app/controllers/api/v1/pd/international_opt_ins_controller.rb b/dashboard/app/controllers/api/v1/pd/international_opt_ins_controller.rb new file mode 100644 index 0000000000000..51b03860c391f --- /dev/null +++ b/dashboard/app/controllers/api/v1/pd/international_opt_ins_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Pd::InternationalOptInsController < Api::V1::Pd::FormsController + authorize_resource class: 'Pd::InternationalOptIn', only: :create + + def new_form + @contact_form = ::Pd::InternationalOptIn.new( + user: current_user + ) + end + + def on_successful_create + EmailPreference.upsert!( + email: @contact_form.email, + opt_in: @contact_form.email_opt_in?, + ip_address: request.ip, + source: EmailPreference::FORM_PD_INTERNATIONAL_OPT_IN, + form_kind: "0" + ) + end +end diff --git a/dashboard/app/controllers/omniauth_callbacks_controller.rb b/dashboard/app/controllers/omniauth_callbacks_controller.rb index cf6b481312683..324f3094e14aa 100644 --- a/dashboard/app/controllers/omniauth_callbacks_controller.rb +++ b/dashboard/app/controllers/omniauth_callbacks_controller.rb @@ -1,4 +1,5 @@ require 'cdo/shared_cache' +require 'honeybadger' class OmniauthCallbacksController < Devise::OmniauthCallbacksController include UsersHelper @@ -79,8 +80,8 @@ def login # If email is already taken, persisted? will be false because of a validation failure check_and_apply_oauth_takeover(@user) sign_in_user - elsif allows_silent_takeover(@user) - silent_takeover(@user) + elsif allows_silent_takeover(@user, auth_hash) + silent_takeover(@user, auth_hash) elsif (looked_up_user = User.find_by_email_or_hashed_email(@user.email)) # Note that @user.email is populated by User.from_omniauth even for students if looked_up_user.provider == 'clever' @@ -174,14 +175,36 @@ def just_authorized_google_classroom(user, params) scopes.include?('classroom.rosters.readonly') end - def silent_takeover(oauth_user) + def silent_takeover(oauth_user, auth_hash) # Copy oauth details to primary account @user = User.find_by_email_or_hashed_email(oauth_user.email) - @user.provider = oauth_user.provider - @user.uid = oauth_user.uid - @user.oauth_refresh_token = oauth_user.oauth_refresh_token - @user.oauth_token = oauth_user.oauth_token - @user.oauth_token_expiration = oauth_user.oauth_token_expiration + if @user.migrated? + success = AuthenticationOption.create( + user: @user, + email: oauth_user.email, + hashed_email: oauth_user.hashed_email, + credential_type: auth_hash.provider.to_s, + authentication_id: auth_hash.uid, + data: { + oauth_token: auth_hash.credentials&.token, + oauth_token_expiration: auth_hash.credentials&.expires_at, + oauth_refresh_token: auth_hash.credentials&.refresh_token + } + ) + unless success + # This should never happen if other logic is working correctly, so notify + Honeybadger.notify( + error_class: 'Failed to create AuthenticationOption during silent takeover', + error_message: "Could not create AuthenticationOption during silent takeover for user with email #{oauth_user.email}" + ) + end + else + @user.provider = oauth_user.provider + @user.uid = oauth_user.uid + @user.oauth_refresh_token = oauth_user.oauth_refresh_token + @user.oauth_token = oauth_user.oauth_token + @user.oauth_token_expiration = oauth_user.oauth_token_expiration + end sign_in_user end @@ -190,10 +213,9 @@ def sign_in_user sign_in_and_redirect @user end - def allows_silent_takeover(oauth_user) - allow_takeover = oauth_user.provider.present? - allow_takeover &= %w(facebook google_oauth2 windowslive).include?(oauth_user.provider) - # allow_takeover &= oauth_user.email_verified # TODO (eric) - set up and test for different providers + def allows_silent_takeover(oauth_user, auth_hash) + allow_takeover = auth_hash.provider.present? + allow_takeover &= AuthenticationOption::SILENT_TAKEOVER_CREDENTIAL_TYPES.include?(auth_hash.provider.to_s) lookup_user = User.find_by_email_or_hashed_email(oauth_user.email) allow_takeover && lookup_user end diff --git a/dashboard/app/controllers/pd/international_opt_in_controller.rb b/dashboard/app/controllers/pd/international_opt_in_controller.rb new file mode 100644 index 0000000000000..9eeb148835f09 --- /dev/null +++ b/dashboard/app/controllers/pd/international_opt_in_controller.rb @@ -0,0 +1,19 @@ +class Pd::InternationalOptInController < ApplicationController + load_resource :international_opt_in, class: 'Pd::InternationalOptIn', id_param: :contact_id, only: [:thanks] + + # GET /pd/international_workshopss/new + def new + return render '/pd/application/teacher_application/logged_out' unless current_user + return render '/pd/application/teacher_application/not_teacher' unless current_user.teacher? + return render '/pd/application/teacher_application/no_teacher_email' unless current_user.email + + @script_data = { + props: { + options: Pd::InternationalOptIn.options.camelize_keys, + accountEmail: current_user.email, + apiEndpoint: "/api/v1/pd/international_opt_ins", + labels: Pd::InternationalOptIn.labels + }.to_json + } + end +end diff --git a/dashboard/app/controllers/registrations_controller.rb b/dashboard/app/controllers/registrations_controller.rb index 97bc8cc651ea7..f1ba046f2ec73 100644 --- a/dashboard/app/controllers/registrations_controller.rb +++ b/dashboard/app/controllers/registrations_controller.rb @@ -74,6 +74,7 @@ def sign_up_params # Set age for the current user if empty - skips CSRF verification because this can be called # from cached pages which will not populate the CSRF token def set_age + return head(:forbidden) unless current_user current_user.update(age: params[:user][:age]) unless current_user.age.present? end diff --git a/dashboard/app/helpers/pd/workshop_survey_results_helper.rb b/dashboard/app/helpers/pd/workshop_survey_results_helper.rb index fb25003c49332..bcd014aad81ca 100644 --- a/dashboard/app/helpers/pd/workshop_survey_results_helper.rb +++ b/dashboard/app/helpers/pd/workshop_survey_results_helper.rb @@ -183,6 +183,7 @@ def generate_workshops_survey_summary(workshop, questions) surveys = get_surveys_for_workshops(workshop) workshop_summary = {} + facilitator_map = Hash[*workshop.facilitators.pluck(:id, :name).flatten] # Each session has a general response section. # Some also have a facilitator response section @@ -211,7 +212,7 @@ def generate_workshops_survey_summary(workshop, questions) if current_user&.facilitator? facilitator_responses.slice! current_user.id end - session_summary[:facilitator][q_key] = facilitator_responses + session_summary[:facilitator][q_key] = facilitator_responses.transform_keys {|k| facilitator_map[k]} else # Otherwise, we just want a list of all responses sum = surveys_for_session[response_section].map {|survey| survey[q_key]}.reduce([], :append).compact @@ -239,7 +240,7 @@ def generate_workshops_survey_summary(workshop, questions) facilitator_response_sums.each do |facilitator_id, response_sums| facilitator_response_averages[facilitator_id] = (response_sums[:sum] / response_sums[:responses].to_f).round(2) end - session_summary[:facilitator][q_key] = facilitator_response_averages + session_summary[:facilitator][q_key] = facilitator_response_averages.transform_keys {|k| facilitator_map[k]} else # For non facilitator specific responses, just return a frequency map with # nulls removed diff --git a/dashboard/app/models/ability.rb b/dashboard/app/models/ability.rb index 3c0a2d97ad5a1..4d21ed221651c 100644 --- a/dashboard/app/models/ability.rb +++ b/dashboard/app/models/ability.rb @@ -48,6 +48,7 @@ def initialize(user) Pd::Application::ApplicationBase, Pd::Application::Facilitator1819Application, Pd::Application::Teacher1819Application, + Pd::InternationalOptIn, :maker_discount ] @@ -92,6 +93,7 @@ def initialize(user) can [:new, :create, :read], Pd::WorkshopMaterialOrder, user_id: user.id can [:new, :create, :read], Pd::Application::Facilitator1819Application, user_id: user.id can [:new, :create, :read], Pd::Application::Teacher1819Application, user_id: user.id + can :create, Pd::InternationalOptIn, user_id: user.id can :manage, :maker_discount end diff --git a/dashboard/app/models/authentication_option.rb b/dashboard/app/models/authentication_option.rb index 13ecb00733501..d884cbd52e1c9 100644 --- a/dashboard/app/models/authentication_option.rb +++ b/dashboard/app/models/authentication_option.rb @@ -48,6 +48,12 @@ class AuthenticationOption < ApplicationRecord OAUTH_CREDENTIAL_TYPES, ].flatten + SILENT_TAKEOVER_CREDENTIAL_TYPES = [ + FACEBOOK, + GOOGLE, + WINDOWS_LIVE + ] + def oauth? OAUTH_CREDENTIAL_TYPES.include? credential_type end @@ -83,6 +89,15 @@ def data_hash end end + def summarize + { + id: id, + credential_type: credential_type, + email: email, + hashed_email: hashed_email + } + end + private def email_must_be_unique # skip the db lookup if possible return unless email_changed? && email.present? && errors.blank? diff --git a/dashboard/app/models/pd/international_opt_in.rb b/dashboard/app/models/pd/international_opt_in.rb new file mode 100644 index 0000000000000..5f9581f0c8cd2 --- /dev/null +++ b/dashboard/app/models/pd/international_opt_in.rb @@ -0,0 +1,95 @@ +# == Schema Information +# +# Table name: pd_international_opt_ins +# +# id :integer not null, primary key +# user_id :integer not null +# form_data :text(65535) not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_pd_international_opt_ins_on_user_id (user_id) +# + +class Pd::InternationalOptIn < ApplicationRecord + include Pd::Form + include InternationalOptInPeople + + belongs_to :user + + validates_presence_of :user_id, :form_data + + def self.required_fields + [ + :first_name, + :last_name, + :gender, + :school_name, + :school_city, + :school_country, + :ages, + :subjects, + :resources, + :workshop_organizer, + :workshop_facilitator, + :workshop_course, + :email_opt_in, + :legal_opt_in + ] + end + + def self.options + entry_keys = { + gender: %w(male female non_binary not_listed none), + schoolCountry: %w(canada chile israel malaysia mexico thailand), + ages: %w(ages_under_6 ages_7_8 ages_9_10 ages_11_12 ages_13_14 ages_15_16 ages_17_18 ages_19_over), + subjects: %w(cs ict math science history la efl music art other), + resources: %w(bootstrap codecademy csfirst khan kodable lightbot scratch tynker other), + robotics: %w(grok kodable lego microbit ozobot sphero raspberry wonder other), + workshopCourse: %w(csf_af csf_express), + emailOptIn: %w(opt_in_yes opt_in_no), + legalOptIn: %w(opt_in_yes opt_in_no) + } + + entries = Hash[entry_keys.map {|k, v| [k, v.map {|s| I18n.t("pd.form_entries.#{k.to_s.underscore}.#{s.underscore}")}]}] + + entries[:workshopOrganizer] = INTERNATIONAL_OPT_IN_PARTNERS + entries[:workshopFacilitator] = INTERNATIONAL_OPT_IN_FACILITATORS + + super.merge(entries) + end + + def self.labels + keys = %w( + firstName + firstNamePreferred + lastName + email + emailAlternate + gender + schoolName + schoolCity + schoolCountry + ages + subjects + resources + robotics + workshopOrganizer + workshopFacilitator + workshopCourse + emailOptIn + legalOptIn + ) + Hash[keys.collect {|v| [v, I18n.t("pd.form_labels.#{v.underscore}")]}] + end + + def email_opt_in? + sanitize_form_data_hash[:email_opt_in].downcase == "yes" + end + + def email + sanitize_form_data_hash[:email] + end +end diff --git a/dashboard/app/models/user.rb b/dashboard/app/models/user.rb index 430ba55d57189..35a79a0e3a82e 100644 --- a/dashboard/app/models/user.rb +++ b/dashboard/app/models/user.rb @@ -844,6 +844,16 @@ def secret_picture_account_only? any_sections && sections_as_student.all? {|section| section.login_type == Section::LOGIN_TYPE_PICTURE} end + # True if user is a student in a section that has Google Classroom login type + def google_classroom_student? + sections_as_student.find_by_login_type(Section::LOGIN_TYPE_GOOGLE_CLASSROOM).present? + end + + # True if user is a student in a section that has Clever login type + def clever_student? + sections_as_student.find_by_login_type(Section::LOGIN_TYPE_CLEVER).present? + end + # overrides Devise::Authenticatable#find_first_by_auth_conditions # see https://github.com/plataformatec/devise/blob/master/lib/devise/models/authenticatable.rb#L245 def self.find_for_authentication(tainted_conditions) diff --git a/dashboard/app/views/devise/registrations/edit.html.haml b/dashboard/app/views/devise/registrations/edit.html.haml index 4871b18d6ed56..0125861adc5b6 100644 --- a/dashboard/app/views/devise/registrations/edit.html.haml +++ b/dashboard/app/views/devise/registrations/edit.html.haml @@ -209,7 +209,10 @@ userAge: current_user.age, userType: current_user.user_type, isOauth: current_user.oauth?, - isPasswordRequired: current_user.encrypted_password.present? + isPasswordRequired: current_user.encrypted_password.present?, + authenticationOptions: current_user.authentication_options.map(&:summarize), + isGoogleClassroomStudent: current_user.google_classroom_student?, + isCleverStudent: current_user.clever_student?, }.to_json } %script{src: minifiable_asset_path('js/devise/registrations/edit.js'), data: script_data} diff --git a/dashboard/app/views/pd/application/teacher_application/logged_out.haml b/dashboard/app/views/pd/application/teacher_application/logged_out.haml index f3fa5aa8a3262..3f2449e81a3c2 100644 --- a/dashboard/app/views/pd/application/teacher_application/logged_out.haml +++ b/dashboard/app/views/pd/application/teacher_application/logged_out.haml @@ -2,17 +2,14 @@ = stylesheet_link_tag 'css/pd', media: 'all' %h1 - Thanks for your interest in the Professional Learning Program! + = I18n.t('pd.logged_out.heading') %p - To get started, you first need to be logged into your Code.org account. - If you’d like more information about the program before you start your application, - please check out the - = link_to('Professional Learning Program overview', 'https://code.org/educate/professional-learning') + '.' + != I18n.t('pd.logged_out.body', url: CDO.code_org_url('/educate/professional-learning')) %div = link_to "/users/sign_in?user_return_to=#{request.fullpath}" do - %button= 'Sign in' + %button= I18n.t('nav.user.signin') = link_to "/users/sign_up?user[user_type]=teacher&user_return_to=#{request.fullpath}" do - %button= 'Create an account' + %button= I18n.t('nav.user.signup') diff --git a/dashboard/app/views/pd/application/teacher_application/no_teacher_email.haml b/dashboard/app/views/pd/application/teacher_application/no_teacher_email.haml new file mode 100644 index 0000000000000..951d398289740 --- /dev/null +++ b/dashboard/app/views/pd/application/teacher_application/no_teacher_email.haml @@ -0,0 +1,5 @@ +- content_for(:head) do + = stylesheet_link_tag 'css/pd', media: 'all' + +%p + != I18n.t('pd.no_teacher_email.body', user_registration: edit_user_registration_path) diff --git a/dashboard/app/views/pd/application/teacher_application/not_teacher.haml b/dashboard/app/views/pd/application/teacher_application/not_teacher.haml index 6f810c3c750ca..0d76101f8f0db 100644 --- a/dashboard/app/views/pd/application/teacher_application/not_teacher.haml +++ b/dashboard/app/views/pd/application/teacher_application/not_teacher.haml @@ -2,12 +2,7 @@ = stylesheet_link_tag 'css/pd', media: 'all' %p - Thanks for your interest in Code.org’s Professional Learning Program! - You’re currently signed into a student account. Please either - = link_to('create a teacher account', '/users/sign_up?user%5Buser_type%5D=teacher') - or - = link_to('go to your user settings', '/users/edit') - to upgrade your account from student to teacher. - If you’d like more information about the program before you start your application, - please check out the - = link_to('Professional Learning Program overview', 'https://code.org/educate/professional-learning') + '.' + != I18n.t('pd.not_teacher.body', create_teacher: '/users/sign_up?user%5Buser_type%5D=teacher', user_settings: '/users/edit') + +%p + != I18n.t('pd.not_teacher.more', pl_overview: CDO.code_org_url('/educate/professional-learning')) diff --git a/dashboard/app/views/pd/international_opt_in/new.html.haml b/dashboard/app/views/pd/international_opt_in/new.html.haml new file mode 100644 index 0000000000000..1b033d8518406 --- /dev/null +++ b/dashboard/app/views/pd/international_opt_in/new.html.haml @@ -0,0 +1,17 @@ +- @page_title = I18n.t('pd.international_opt_in.title') + +- content_for(:head) do + = stylesheet_link_tag 'css/pd', media: 'all' +%script{src: minifiable_asset_path('js/pd/international_opt_in/new.js'), data: @script_data} + +#application-header + %h3 + = I18n.t('pd.international_opt_in.title') + +%p + = I18n.t('pd.international_opt_in.intro') + +%p + = I18n.t('pd.international_opt_in.instructions') + +%div#application-container diff --git a/dashboard/app/views/pd/international_opt_in/thanks.html.haml b/dashboard/app/views/pd/international_opt_in/thanks.html.haml new file mode 100644 index 0000000000000..2ba9b35e5982a --- /dev/null +++ b/dashboard/app/views/pd/international_opt_in/thanks.html.haml @@ -0,0 +1 @@ += I18n.t('pd.international_opt_in.thanks') diff --git a/dashboard/config/locales/en.yml b/dashboard/config/locales/en.yml index bc5104cb126c3..8f0e8a2762d8a 100644 --- a/dashboard/config/locales/en.yml +++ b/dashboard/config/locales/en.yml @@ -1024,6 +1024,98 @@ en: things_you_liked_s: 'What were the two things you liked most about the activities you did in this workshop and why?' things_you_would_change_s: 'What are the two things you would change about the activities you did in this workshop?' anything_else_s: 'Is there anything else you’d like to tell us about your experience at this workshop?' + form_labels: + first_name: 'First Name' + first_name_preferred: 'Preferred First Name' + last_name: 'Last Name' + email: 'Email' + email_alternate: 'Alternate Email' + gender: 'Gender Identity' + school_name: 'School Name' + school_city: 'School City' + school_country: 'School Country' + ages: 'Which age(s) do you teach this year?' + subjects: 'Which subject(s) do you teach this year?' + resources: 'Which of the following CS education resources do you use?' + robotics: 'Which of the following robotics resources do you use?' + workshop_organizer: 'Workshop Organizer' + workshop_facilitator: 'Workshop Facilitator' + workshop_course: 'Workshop Course' + email_opt_in: 'I agree that Code.org can share my contact information and aggregate data about my classes with the Code.org International Partner in my country.' + legal_opt_in: 'By submitting this form, you are agreeing to allow Code.org to share information on how you use Code.org and the Professional Learning resources with the Code.org International Partner in your country, school district, and facilitators. We will share your contact information, which courses/units you are using and aggregate data about your classes with these partners. This includes the number of students in your classes, the demographic breakdown of your classroom, and the name of your school and district. We will not share any information about individual students with our partners: all student information will be de-identified and aggregated. Our International Partners and facilitators are contractually obliged to treat this information with the same level of confidentiality as Code.org.' + form_entries: + gender: + male: 'Male' + female: 'Female' + non_binary: 'Non-binary' + not_listed: 'Preferred term not listed' + none: 'Prefer not to answer' + school_country: + canada: 'Canada' + chile: 'Chile' + israel: 'Israel' + malaysia: 'Malaysia' + mexico: 'Mexico' + thailand: 'Thailand' + ages: + ages_under_6: '< 6 years old' + ages_7_8: '7-8 years old' + ages_9_10: '9-10 years old' + ages_11_12: '11-12 years old' + ages_13_14: '13-14 years old' + ages_15_16: '15-16 years old' + ages_17_18: '17-18 years old' + ages_19_over: '19+ years old' + subjects: + cs: 'Computer Science' + ict: 'ICT' + math: 'Math' + science: 'Science' + history: 'History / Social Studies' + la: 'Language Arts' + efl: 'English as a Foreign Language' + music: 'Music' + art: 'Art' + other: "Other:" + resources: + bootstrap: 'Bootstrap' + codecademy: 'CodeCademy' + csfirst: 'Google CS First' + khan: 'Khan Academy' + kodable: 'Kodable' + lightbot: 'Lightbot' + scratch: 'Scratch' + tynker: 'Tynker' + other: "Other:" + robotics: + grok: 'Grok Learning' + kodable: 'Kodable' + lego: 'LEGO Education' + microbit: 'Microbit' + ozobot: 'Ozobot' + sphero: 'Sphero' + raspberry: 'Raspberry Pi' + wonder: 'Wonder Workshop' + other: "Other:" + workshop_course: + csf_af: 'CS Fundamentals (Courses A-F)' + csf_express: 'CS Fundamentals (Pre-Express or Express)' + email_opt_in: + opt_in_yes: 'Yes' + opt_in_no: 'No' + international_opt_in: + title: 'Workshop Attendance for non-U.S. Teachers' + intro: "Thank you for taking time to receive training on Code.org’s curriculum. Our mission to bring computer science education opportunities to all children in all schools around the world would not be possible without you." + instructions: "Please fill out the form below to indicate that 1) you attended a training workshop run by one of Code.org’s international partners, and 2) you agree to share your information with our international partner in your country so that we can help to support your journey as a computer science teacher." + thanks: "Thank you for submitting! We look forward to supporting you as you get to know Code.org’s curriculum and tools. Please keep sharing your questions and feedback with us by writing to us at support@code.org or by posting on the teacher forum!" + logged_out: + heading: 'Thanks for your interest in the Professional Learning Program!' + body: 'To get started, you first need to be logged into your Code.org account. If you’d like more information about the program before you start your application, please check out the Professional Learning Program overview.' + not_teacher: + body: 'Thanks for your interest in Code.org’s Professional Learning Program! You’re currently signed into a student account. Please either create a teacher account or go to your user settings to upgrade your account from student to teacher.' + more: 'If you’d like more information about the program before you start your application, please check out the Professional Learning Program overview.' + no_teacher_email: + body: 'Your account doesn’t have an email address. Please visit your account settings to add your email address before completing this form.' courses_category: 'Full Courses' cookie_banner: message: 'By continuing to browse our site or clicking "I agree," you agree to the storing of cookies on your computer or device.' @@ -1036,3 +1128,4 @@ en: privacy_policy: 'privacy policy' logout: 'Logout' yes: 'Yes' + diff --git a/dashboard/config/routes.rb b/dashboard/config/routes.rb index d7034e26b80f6..3f22f4c3fcbf8 100644 --- a/dashboard/config/routes.rb +++ b/dashboard/config/routes.rb @@ -408,6 +408,7 @@ post :workshop_surveys, to: 'workshop_surveys#create' post :teachercon_surveys, to: 'teachercon_surveys#create' post :regional_partner_contacts, to: 'regional_partner_contacts#create' + post :international_opt_ins, to: 'international_opt_ins#create' get :regional_partner_workshops, to: 'regional_partner_workshops#index' get 'regional_partner_workshops/find', to: 'regional_partner_workshops#find' @@ -505,6 +506,9 @@ get 'regional_partner_contact/new', to: 'regional_partner_contact#new' get 'regional_partner_contact/:contact_id/thanks', to: 'regional_partner_contact#thanks' + get 'international_workshop', to: 'international_opt_in#new' + get 'international_workshop/:contact_id/thanks', to: 'international_opt_in#thanks' + # React-router will handle sub-routes on the client. get 'application_dashboard/*path', to: 'application_dashboard#index' get 'application_dashboard', to: 'application_dashboard#index' diff --git a/dashboard/db/migrate/20180620110434_create_pd_international_opt_ins.rb b/dashboard/db/migrate/20180620110434_create_pd_international_opt_ins.rb new file mode 100644 index 0000000000000..b5da051a930a9 --- /dev/null +++ b/dashboard/db/migrate/20180620110434_create_pd_international_opt_ins.rb @@ -0,0 +1,9 @@ +class CreatePdInternationalOptIns < ActiveRecord::Migration[5.0] + def change + create_table :pd_international_opt_ins do |t| + t.references :user, index: true, null: false + t.text :form_data, null: false + t.timestamps null: false + end + end +end diff --git a/dashboard/db/schema.rb b/dashboard/db/schema.rb index e0256936b21c9..903c10e14e186 100644 --- a/dashboard/db/schema.rb +++ b/dashboard/db/schema.rb @@ -672,6 +672,14 @@ t.index ["pd_application_id"], name: "index_pd_fit_weekend1819_registrations_on_pd_application_id", using: :btree end + create_table "pd_international_opt_ins", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t| + t.integer "user_id", null: false + t.text "form_data", limit: 65535, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_pd_international_opt_ins_on_user_id", using: :btree + end + create_table "pd_payment_terms", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t| t.integer "regional_partner_id", null: false t.date "start_date", null: false diff --git a/dashboard/db/schema_cache.dump b/dashboard/db/schema_cache.dump index b6d334c7a3f7e..be07697b3926d 100644 Binary files a/dashboard/db/schema_cache.dump and b/dashboard/db/schema_cache.dump differ diff --git a/dashboard/lib/international_opt_in_people.rb b/dashboard/lib/international_opt_in_people.rb new file mode 100644 index 0000000000000..7f175100f3c6e --- /dev/null +++ b/dashboard/lib/international_opt_in_people.rb @@ -0,0 +1,18 @@ +# +# These lists of international facilitators and partners are used by the form at +# /pd/international_workshop which creates an InternationalOptIn. They are +# stored separately here so that they can be modified directly. +# +module InternationalOptInPeople + INTERNATIONAL_OPT_IN_FACILITATORS = [ + "First facilitator", + "Second facilitator", + "Third facilitator" + ].freeze + + INTERNATIONAL_OPT_IN_PARTNERS = [ + "First partner", + "Second partner", + "Third partner" + ].freeze +end diff --git a/dashboard/lib/pd/jot_form/constants.rb b/dashboard/lib/pd/jot_form/constants.rb index 6eb48567ba835..658385cf7d601 100644 --- a/dashboard/lib/pd/jot_form/constants.rb +++ b/dashboard/lib/pd/jot_form/constants.rb @@ -25,12 +25,9 @@ module Constants ANSWER_TYPES = [ ANSWER_TEXT = 'text'.freeze, - # We can convert the text response to a numeric value for certain single select controls - # (radio, dropdown, scale, part of a matrix). - ANSWER_SELECT_VALUE = 'selectValue'.freeze, - ANSWER_SELECT_TEXT = 'selectText'.freeze, + ANSWER_SCALE = 'scale'.freeze, - # Multi-select is always text + ANSWER_SINGLE_SELECT = 'singleSelect'.freeze, ANSWER_MULTI_SELECT = 'multiSelect'.freeze, # No answer, just question metadata, e.g. matrix heading diff --git a/dashboard/lib/pd/jot_form/matrix_question.rb b/dashboard/lib/pd/jot_form/matrix_question.rb index c36973f2cb818..1ce71daf3ba5a 100644 --- a/dashboard/lib/pd/jot_form/matrix_question.rb +++ b/dashboard/lib/pd/jot_form/matrix_question.rb @@ -49,15 +49,15 @@ def get_value(answer) raise "Unable to process matrix answer: #{answer}" unless answer.is_a? Hash # Matrix answer is a Hash of sub_question => string_answer + # Validate each answer and convert each key to sub_question_index answer.reject {|_, v| v.blank?}.map do |sub_question, sub_answer| sub_question_index = sub_questions.index(sub_question) raise "Unable to find sub-question '#{sub_question}' in matrix question #{id}" unless sub_question_index - sub_answer_index = options.index(sub_answer) - raise "Unable to find '#{sub_answer}' in the options for matrix question #{id}" unless sub_answer_index + raise "Unable to find '#{sub_answer}' in the options for matrix question #{id}" unless options.include? sub_answer # Return a 1-based value - [sub_question_index, sub_answer_index + 1] + [sub_question_index, sub_answer] end.to_h end @@ -66,9 +66,11 @@ def summarize [ generate_sub_question_key(i), { + parent: name, + max_value: options.length, text: "#{text} #{sub_question}", - answer_type: ANSWER_SELECT_VALUE, - max_value: options.length + answer_type: answer_type, + options: options } ] end.to_h @@ -82,6 +84,11 @@ def process_answer(answer) def generate_sub_question_key(sub_question_index) "#{name}_#{sub_question_index}" end + + # @override + def answer_type + ANSWER_SINGLE_SELECT + end end end end diff --git a/dashboard/lib/pd/jot_form/question_with_options.rb b/dashboard/lib/pd/jot_form/question_with_options.rb index c836166742b4d..a747945e07764 100644 --- a/dashboard/lib/pd/jot_form/question_with_options.rb +++ b/dashboard/lib/pd/jot_form/question_with_options.rb @@ -9,8 +9,22 @@ def to_h ) end + # @override def answer_type - ANSWER_SELECT_VALUE + ANSWER_SINGLE_SELECT + end + + def multi_select? + answer_type == ANSWER_MULTI_SELECT + end + + def single_select? + answer_type == ANSWER_SINGLE_SELECT + end + + # @override + def type_specific_summary + {options: options} end end end diff --git a/dashboard/lib/pd/jot_form/scale_question.rb b/dashboard/lib/pd/jot_form/scale_question.rb index 129f3507f24b7..6f41fe0f98ca1 100644 --- a/dashboard/lib/pd/jot_form/scale_question.rb +++ b/dashboard/lib/pd/jot_form/scale_question.rb @@ -39,7 +39,20 @@ def get_value(answer) # @override def type_specific_summary - {max_value: values.last} + augmented_options = values.map(&:to_s) + augmented_options[0] = "#{augmented_options[0]} - #{options.first}" + augmented_options[-1] = "#{augmented_options[-1]} - #{options.last}" + + { + min_value: values.first, + max_value: values.last, + options: augmented_options + } + end + + # @override + def answer_type + ANSWER_SCALE end end end diff --git a/dashboard/lib/pd/jot_form/select_question.rb b/dashboard/lib/pd/jot_form/select_question.rb index 56cca95e26acc..fbec4c458f7b3 100644 --- a/dashboard/lib/pd/jot_form/select_question.rb +++ b/dashboard/lib/pd/jot_form/select_question.rb @@ -16,7 +16,7 @@ def self.supported_types attr_accessor( :allow_other, :other_text, - :preserve_text + :preserve_text # kept for backward compatibility ) def self.from_jotform_question(jotform_question) @@ -33,20 +33,23 @@ def to_h super.merge( allow_other: allow_other, other_text: other_text, - preserve_text: preserve_text ) end - def answer_type - if type == TYPE_CHECKBOX - ANSWER_MULTI_SELECT - elsif allow_other || preserve_text - ANSWER_SELECT_TEXT - else - # We can only assume a numeric value for single-select (dropdown/radio), - # without an "other" option, and not explicitly set to preserve text. - ANSWER_SELECT_VALUE + # @override + def ensure_valid_answer(answer) + if answer.is_a? Array + answer.each {|sub_answer| ensure_valid_answer(sub_answer)} + elsif !options.include? answer + raise "Unrecognized answer '#{answer}' for question #{id} (Options: #{options.join(',')})" end + + answer + end + + # @override + def answer_type + type == TYPE_CHECKBOX ? ANSWER_MULTI_SELECT : ANSWER_SINGLE_SELECT end def get_value(answer) @@ -59,23 +62,15 @@ def get_value(answer) values_with_other = answer.map {|k, v| k == OTHER_ANSWER_KEY ? v.presence || other_text : v} # It might be a single item or an array - return answer_type == ANSWER_MULTI_SELECT ? values_with_other : values_with_other.first - end - - return answer unless answer_type == ANSWER_SELECT_VALUE - - index = options.index(answer) - unless index - raise "Unrecognized answer '#{answer}' for question #{id} (Options: #{options.to_csv.strip})" + return multi_select? ? values_with_other : values_with_other.first end - # Return a 1-based value - index + 1 + ensure_valid_answer answer end # @override def type_specific_summary - answer_type == ANSWER_SELECT_VALUE ? {max_value: options.length} : {} + {options: options} end end end diff --git a/dashboard/test/controllers/api/v1/pd/international_opt_ins_controller_test.rb b/dashboard/test/controllers/api/v1/pd/international_opt_ins_controller_test.rb new file mode 100644 index 0000000000000..e933b64987a6b --- /dev/null +++ b/dashboard/test/controllers/api/v1/pd/international_opt_ins_controller_test.rb @@ -0,0 +1,59 @@ +require 'test_helper' + +class Api::V1::Pd::InternationalOptInsControllerTest < ::ActionController::TestCase + SAMPLE_FORM_DATA = { + first_name: 'First', + first_name_preferred: 'Preferred', + last_name: 'Last', + email: 'foo@bar.com', + email_alternate: 'footoo@bar.com', + gender: 'Prefer not to answer', + school_name: 'School Name', + school_city: 'School City', + school_country: 'School Country', + ages: ['19+ years old'], + subjects: ['ICT'], + resources: ['Kodable'], + robotics: ['LEGO Education'], + workshop_organizer: 'Workshop Organizer', + workshop_facilitator: 'Workshop Facilitator', + workshop_course: 'Workshop Course', + email_opt_in: 'Yes', + legal_opt_in: true + } + + self.use_transactional_test_case = true + setup_all do + @teacher = create :teacher + end + + test 'create creates a new international opt-in' do + sign_in @teacher + assert_creates Pd::InternationalOptIn do + put :create, params: { + form_data: SAMPLE_FORM_DATA, + user: @teacher + } + assert_response :created + end + + assert_response :created + end + + test 'create returns appropriate errors if international opt-in data is missing' do + sign_in @teacher + + new_form = SAMPLE_FORM_DATA.dup + new_form.delete :last_name + + assert_does_not_create Pd::InternationalOptIn do + put :create, params: { + form_data: new_form, + user: @teacher + } + assert_response :bad_request + end + + assert_response :bad_request + end +end diff --git a/dashboard/test/controllers/omniauth_callbacks_controller_test.rb b/dashboard/test/controllers/omniauth_callbacks_controller_test.rb index 60539d13d1258..056e9411ea6ef 100644 --- a/dashboard/test/controllers/omniauth_callbacks_controller_test.rb +++ b/dashboard/test/controllers/omniauth_callbacks_controller_test.rb @@ -414,6 +414,155 @@ class OmniauthCallbacksControllerTest < ActionController::TestCase assert_equal migrated_student.id, signed_in_user_id end + test 'login: google_oauth2 silently takes over unmigrated student with matching email' do + email = 'test@foo.xyz' + uid = '654321' + user = create(:student, email: email) + auth = generate_auth_user_hash(provider: 'google_oauth2', uid: uid, user_type: User::TYPE_STUDENT, email: email) + @request.env['omniauth.auth'] = auth + @request.env['omniauth.params'] = {} + assert_does_not_create(User) do + get :google_oauth2 + end + user.reload + assert_equal 'google_oauth2', user.provider + assert_equal user.uid, uid + end + + test 'login: google_oauth2 silently takes over unmigrated teacher with matching email' do + email = 'test@foo.xyz' + uid = '654321' + user = create(:teacher, email: email) + auth = generate_auth_user_hash(provider: 'google_oauth2', uid: uid, user_type: User::TYPE_TEACHER, email: email) + @request.env['omniauth.auth'] = auth + @request.env['omniauth.params'] = {} + assert_does_not_create(User) do + get :google_oauth2 + end + user.reload + assert_equal 'google_oauth2', user.provider + assert_equal user.uid, uid + end + + test 'login: google_oauth2 silently adds authentication_option to migrated student with matching email' do + email = 'test@foo.xyz' + uid = '654321' + user = create(:student, :with_migrated_email_authentication_option, email: email) + auth = generate_auth_user_hash(provider: 'google_oauth2', uid: uid, user_type: User::TYPE_STUDENT, email: email) + @request.env['omniauth.auth'] = auth + @request.env['omniauth.params'] = {} + assert_does_not_create(User) do + get :google_oauth2 + end + user.reload + assert_equal 'migrated', user.provider + found_google = user.authentication_options.any? {|auth_option| auth_option.credential_type == AuthenticationOption::GOOGLE} + assert found_google + end + + test 'login: google_oauth2 silently adds authentication_option to migrated teacher with matching email' do + email = 'test@foo.xyz' + uid = '654321' + user = create(:teacher, :with_migrated_email_authentication_option, email: email) + auth = generate_auth_user_hash(provider: 'google_oauth2', uid: uid, user_type: User::TYPE_TEACHER, email: email) + @request.env['omniauth.auth'] = auth + @request.env['omniauth.params'] = {} + assert_does_not_create(User) do + get :google_oauth2 + end + user.reload + assert_equal 'migrated', user.provider + found_google = user.authentication_options.any? {|auth_option| auth_option.credential_type == AuthenticationOption::GOOGLE} + assert found_google + end + + test 'login: clever does not silently add authentication_option to migrated student with matching email' do + email = 'test@foo.xyz' + uid = '654321' + user = create(:student, :with_migrated_email_authentication_option, email: email) + auth = generate_auth_user_hash(provider: 'clever', uid: uid, user_type: User::TYPE_STUDENT, email: email) + @request.env['omniauth.auth'] = auth + @request.env['omniauth.params'] = {} + assert_creates(User) do + get :clever + end + user.reload + assert_equal 'migrated', user.provider + found_clever = user.authentication_options.any? {|auth_option| auth_option.credential_type == AuthenticationOption::CLEVER} + assert !found_clever + assert_equal 'clever', User.last.provider # NOTE: this will fail when we create migrated users by default + end + + test 'connect_provider: can connect multiple auth options with the same email to the same user' do + email = 'test@xyz.foo' + user = create :user, :multi_auth_migrated, uid: 'some-uid' + AuthenticationOption.create!( + { + user: user, + email: email, + hashed_email: User.hash_email(email), + credential_type: 'google_oauth2', + authentication_id: 'some-uid', + data: { + oauth_token: 'fake_token', + oauth_token_expiration: '999999', + oauth_refresh_token: 'fake_refresh_token' + } + } + ) + + auth = generate_auth_user_hash(provider: 'facebook', uid: user.uid, refresh_token: '65432', email: email) + @request.env['omniauth.auth'] = auth + + Timecop.freeze do + setup_should_connect_provider(user, 2.days.from_now) + assert_creates(AuthenticationOption) do + get :facebook + end + + user.reload + assert_redirected_to 'http://test.host/users/edit' + assert_equal 2, user.authentication_options.length + end + end + + test 'connect_provider: cannot connect multiple auth options with the same email to a different user' do + email = 'test@xyz.foo' + user_a = create :user, :multi_auth_migrated + AuthenticationOption.create!( + { + user: user_a, + email: email, + hashed_email: User.hash_email(email), + credential_type: 'google_oauth2', + authentication_id: 'some-uid', + data: { + oauth_token: 'fake_token', + oauth_token_expiration: '999999', + oauth_refresh_token: 'fake_refresh_token' + } + } + ) + + user_b = create :user, :multi_auth_migrated + auth = generate_auth_user_hash(provider: 'facebook', uid: 'some-other-uid', refresh_token: '65432', email: email) + @request.env['omniauth.auth'] = auth + + Timecop.freeze do + setup_should_connect_provider(user_b, 2.days.from_now) + assert_does_not_create(AuthenticationOption) do + get :facebook + end + + assert_redirected_to 'http://test.host/users/edit' + assert_equal 'Email has already been taken', flash.alert + end + user_a.reload + user_b.reload + assert_equal 1, user_a.authentication_options.length + assert_equal 0, user_b.authentication_options.length + end + test 'connect_provider: returns bad_request if user not migrated' do user = create :user, :unmigrated_facebook_sso Timecop.freeze do diff --git a/dashboard/test/controllers/registrations_controller/set_age_test.rb b/dashboard/test/controllers/registrations_controller/set_age_test.rb new file mode 100644 index 0000000000000..03473206bf4ba --- /dev/null +++ b/dashboard/test/controllers/registrations_controller/set_age_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' + +module RegistrationsControllerTests + # + # Tests over PATCH /users/set_age + # + class SetAgeTest < ActionDispatch::IntegrationTest + test "set_age does nothing if user is not signed in" do + User.any_instance.expects(:update).never + patch '/users/set_age', params: {user: {age: '20'}} + assert_response :forbidden + end + + test "set_age does nothing if user age is already set" do + User.any_instance.expects(:update).never + student = create :student, age: 18 + assert_equal 18, student.age + + sign_in student + patch '/users/set_age', params: {user: {age: '20'}} + assert_response :success + + student.reload + assert_equal 18, student.age + end + + test "set_age sets age if user is signed in and age is blank" do + student = create :student_in_picture_section, birthday: nil + assert student.age.blank? + + sign_in student + patch '/users/set_age', params: {user: {age: '20'}} + assert_response :success + + student.reload + assert_equal 20, student.age + end + end +end diff --git a/dashboard/test/factories/pd_factories.rb b/dashboard/test/factories/pd_factories.rb index b687c6a0e82ec..d29a592c0eca1 100644 --- a/dashboard/test/factories/pd_factories.rb +++ b/dashboard/test/factories/pd_factories.rb @@ -543,6 +543,11 @@ form_data nil end + factory :pd_international_opt_in, class: 'Pd::InternationalOptIn' do + user nil + form_data nil + end + factory :pd_regional_partner_cohort, class: 'Pd::RegionalPartnerCohort' do course Pd::Workshop::COURSE_CSP end diff --git a/dashboard/test/helpers/workshop_survey_results_helper_test.rb b/dashboard/test/helpers/workshop_survey_results_helper_test.rb index 1abb201537a91..2c283bd56b83c 100644 --- a/dashboard/test/helpers/workshop_survey_results_helper_test.rb +++ b/dashboard/test/helpers/workshop_survey_results_helper_test.rb @@ -48,7 +48,7 @@ class Pd::WorkshopSurveyResultsHelperTest < ActionView::TestCase id: 1, name: 'sampleDailyScale', text: 'How was your day?', - options: %w(Poor Fair Good Great Excellent), + options: %w(Poor Excellent), values: (1..5).to_a, type: TYPE_SCALE ) @@ -102,8 +102,10 @@ class Pd::WorkshopSurveyResultsHelperTest < ActionView::TestCase general: { 'sampleDailyScale' => { text: 'How was your day?', - answer_type: ANSWER_SELECT_VALUE, - max_value: 5 + answer_type: ANSWER_SCALE, + min_value: 1, + max_value: 5, + options: ['1 - Poor', '2', '3', '4', '5 - Excellent'] }, }, facilitator: { @@ -119,18 +121,24 @@ class Pd::WorkshopSurveyResultsHelperTest < ActionView::TestCase general: { 'sampleMatrix_0' => { text: 'How do you feel about these statements? I am excited for CS Principles', - answer_type: ANSWER_SELECT_VALUE, - max_value: 5 + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Strongly\ Agree Agree Neutral Disagree Strongly\ Disagree), + max_value: 5, + parent: 'sampleMatrix' }, 'sampleMatrix_1' => { text: 'How do you feel about these statements? I am prepared for CS Principles', - answer_type: ANSWER_SELECT_VALUE, - max_value: 5 + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Strongly\ Agree Agree Neutral Disagree Strongly\ Disagree), + max_value: 5, + parent: 'sampleMatrix' }, 'sampleScale' => { text: 'Do you like CS Principles?', - answer_type: ANSWER_SELECT_VALUE, - max_value: 5 + answer_type: ANSWER_SCALE, + min_value: 1, + max_value: 5, + options: ['1 - Strongly Agree', '2', '3', '4', '5 - Strongly Disagree'] }, 'sampleText' => { text: 'Write some thoughts here', @@ -382,21 +390,21 @@ class Pd::WorkshopSurveyResultsHelperTest < ActionView::TestCase assert_equal( { 'Pre Workshop' => { + response_count: 3, general: { 'sampleMatrix_0' => { - 1 => 2, - 4 => 1 + 'Strongly Agree' => 2, + 'Disagree' => 1 }, 'sampleMatrix_1' => { - 2 => 3, + 'Agree' => 3, }, 'sampleScale' => { 2 => 1, 4 => 1 }, 'sampleText' => ['Here are my thoughts', 'More thoughts'] - }, - response_count: 3 + } }, 'Day 1' => daily_expected_results, 'Day 2' => daily_expected_results, diff --git a/dashboard/test/lib/pd/jot_form/form_questions_test.rb b/dashboard/test/lib/pd/jot_form/form_questions_test.rb index 9ad40b9060398..e8c384bffd5a0 100644 --- a/dashboard/test/lib/pd/jot_form/form_questions_test.rb +++ b/dashboard/test/lib/pd/jot_form/form_questions_test.rb @@ -11,11 +11,13 @@ class FormQuestionsTest < ActiveSupport::TestCase @questions = [ TextQuestion.new( id: 1, + order: 1, name: 'text', text: 'text label' ), SelectQuestion.new( id: 2, + order: 2, type: TYPE_RADIO, name: 'singleSelect', text: 'single select label', @@ -23,6 +25,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), SelectQuestion.new( id: 3, + order: 3, name: 'singleSelectWithOther', type: TYPE_RADIO, text: 'single select with other label', @@ -32,6 +35,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), SelectQuestion.new( id: 4, + order: 4, type: TYPE_CHECKBOX, name: 'multiSelect', text: 'multi select label', @@ -39,6 +43,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), SelectQuestion.new( id: 5, + order: 5, type: TYPE_CHECKBOX, name: 'multiSelectWithOther', text: 'multi select with other label', @@ -48,6 +53,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), ScaleQuestion.new( id: 6, + order: 6, name: 'scale', text: 'scale label', options: %w(From To), @@ -55,6 +61,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), MatrixQuestion.new( id: 7, + order: 7, name: 'matrix', text: 'How much do you agree or disagree with the following statements about this workshop?', options: %w(Disagree Neutral Agree), @@ -66,6 +73,7 @@ class FormQuestionsTest < ActiveSupport::TestCase ), TextQuestion.new( id: 8, + order: 8, name: 'hidden_text', text: 'This should be hidden', hidden: true @@ -98,39 +106,50 @@ class FormQuestionsTest < ActiveSupport::TestCase }, 'singleSelect' => { text: 'single select label', - answer_type: ANSWER_SELECT_VALUE, - max_value: 3 + answer_type: ANSWER_SINGLE_SELECT, + options: %w(One Two Three) }, 'singleSelectWithOther' => { text: 'single select with other label', - answer_type: ANSWER_SELECT_TEXT + answer_type: ANSWER_SINGLE_SELECT, + options: %w(One Two Three) }, 'multiSelect' => { text: 'multi select label', - answer_type: ANSWER_MULTI_SELECT + answer_type: ANSWER_MULTI_SELECT, + options: %w(One Two Three) }, 'multiSelectWithOther' => { text: 'multi select with other label', - answer_type: ANSWER_MULTI_SELECT + answer_type: ANSWER_MULTI_SELECT, + options: %w(One Two Three) }, 'scale' => { text: 'scale label', - answer_type: ANSWER_SELECT_VALUE, - max_value: 3 + answer_type: ANSWER_SCALE, + min_value: 1, + max_value: 3, + options: ['1 - From', '2', '3 - To'] }, 'matrix_0' => { text: 'How much do you agree or disagree with the following statements about this workshop? I learned something', - answer_type: ANSWER_SELECT_VALUE, + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Disagree Neutral Agree), + parent: 'matrix', max_value: 3 }, 'matrix_1' => { text: 'How much do you agree or disagree with the following statements about this workshop? It was a good use of time', - answer_type: ANSWER_SELECT_VALUE, + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Disagree Neutral Agree), + parent: 'matrix', max_value: 3 }, 'matrix_2' => { text: 'How much do you agree or disagree with the following statements about this workshop? I enjoyed it', - answer_type: ANSWER_SELECT_VALUE, + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Disagree Neutral Agree), + parent: 'matrix', max_value: 3 } } @@ -141,14 +160,14 @@ class FormQuestionsTest < ActiveSupport::TestCase test 'process_answers' do expected_processed_answers = { 'text' => 'this is my text answer', - 'singleSelect' => 2, + 'singleSelect' => 'Two', 'singleSelectWithOther' => 'my other reason', 'multiSelect' => %w(Two Three), 'multiSelectWithOther' => ['Two', 'my other reason'], 'scale' => 2, - 'matrix_0' => 3, - 'matrix_1' => 2, - 'matrix_2' => 3 + 'matrix_0' => 'Agree', + 'matrix_1' => 'Neutral', + 'matrix_2' => 'Agree' } assert_equal expected_processed_answers, @form_questions.process_answers(@jotform_answers) diff --git a/dashboard/test/lib/pd/jot_form/matrix_question_test.rb b/dashboard/test/lib/pd/jot_form/matrix_question_test.rb index e917fffc87d39..d57ba556c5502 100644 --- a/dashboard/test/lib/pd/jot_form/matrix_question_test.rb +++ b/dashboard/test/lib/pd/jot_form/matrix_question_test.rb @@ -24,7 +24,7 @@ class MatrixQuestionTest < ActiveSupport::TestCase assert_equal 'sampleMatrix', question.name assert_equal 'This is a matrix label', question.text assert_equal 1, question.order - assert_equal ANSWER_SELECT_VALUE, question.answer_type + assert_equal ANSWER_SINGLE_SELECT, question.answer_type assert_equal ['Strongly Agree', 'Agree', 'Neutral', 'Disagree', 'Strongly Disagree'], question.options assert_equal ['Question 1', 'Question 2'], question.sub_questions end @@ -43,7 +43,7 @@ class MatrixQuestionTest < ActiveSupport::TestCase } assert_equal( - {0 => 2, 2 => 1}, + {0 => 'Neutral', 2 => 'Agree'}, question.get_value(answer) ) end @@ -81,12 +81,16 @@ class MatrixQuestionTest < ActiveSupport::TestCase expected_summary = { 'sampleMatrix_0' => { text: 'How much do you agree or disagree with the following statements about this workshop? I learned something', - answer_type: ANSWER_SELECT_VALUE, + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Disagree Neutral Agree), + parent: 'sampleMatrix', max_value: 3 }, 'sampleMatrix_1' => { text: 'How much do you agree or disagree with the following statements about this workshop? It was a good use of time', - answer_type: ANSWER_SELECT_VALUE, + answer_type: ANSWER_SINGLE_SELECT, + options: %w(Disagree Neutral Agree), + parent: 'sampleMatrix', max_value: 3 } } @@ -112,8 +116,8 @@ class MatrixQuestionTest < ActiveSupport::TestCase assert_equal( { - 'sampleMatrix_0' => 3, - 'sampleMatrix_1' => 2 + 'sampleMatrix_0' => 'Agree', + 'sampleMatrix_1' => 'Neutral' }, question.process_answer(answer) ) diff --git a/dashboard/test/lib/pd/jot_form/scale_question_test.rb b/dashboard/test/lib/pd/jot_form/scale_question_test.rb index cbbadec020c81..a3444f273f0e2 100644 --- a/dashboard/test/lib/pd/jot_form/scale_question_test.rb +++ b/dashboard/test/lib/pd/jot_form/scale_question_test.rb @@ -26,7 +26,7 @@ class ScaleQuestionTest < ActiveSupport::TestCase assert_equal 'sampleScale', question.name assert_equal 'This is a scale label', question.text assert_equal 1, question.order - assert_equal ANSWER_SELECT_VALUE, question.answer_type + assert_equal ANSWER_SCALE, question.answer_type assert_equal [1, 2, 3, 4, 5], question.values assert_equal ['Strongly Agree', 'Strongly Disagree'], question.options end @@ -48,7 +48,7 @@ class ScaleQuestionTest < ActiveSupport::TestCase name: 'a name', text: 'label', order: 1, - options: %w(One Two Three), + options: %w(From To), values: [1, 2, 3] } @@ -61,14 +61,17 @@ class ScaleQuestionTest < ActiveSupport::TestCase id: 1, name: 'sampleScale', text: 'a label', - values: (1..5).to_a + values: (1..5).to_a, + options: %w(From To) ) expected_summary = { 'sampleScale' => { text: 'a label', - answer_type: ANSWER_SELECT_VALUE, - max_value: 5 + answer_type: ANSWER_SCALE, + min_value: 1, + max_value: 5, + options: ['1 - From', '2', '3', '4', '5 - To'] } } assert_equal expected_summary, question.summarize diff --git a/dashboard/test/lib/pd/jot_form/select_question_test.rb b/dashboard/test/lib/pd/jot_form/select_question_test.rb index a0797b278d21d..222c6d57b3ace 100644 --- a/dashboard/test/lib/pd/jot_form/select_question_test.rb +++ b/dashboard/test/lib/pd/jot_form/select_question_test.rb @@ -7,8 +7,8 @@ class SelectQuestionTest < ActiveSupport::TestCase include Constants { - TYPE_DROPDOWN => ANSWER_SELECT_VALUE, - TYPE_RADIO => ANSWER_SELECT_VALUE, + TYPE_DROPDOWN => ANSWER_SINGLE_SELECT, + TYPE_RADIO => ANSWER_SINGLE_SELECT, TYPE_CHECKBOX => ANSWER_MULTI_SELECT }.each do |type, expected_answer_type| test "parse jotform question data for #{type}" do @@ -39,29 +39,12 @@ class SelectQuestionTest < ActiveSupport::TestCase end end - test 'questions with preserve_text or an other option do not calculate answer values' do - { - TYPE_DROPDOWN => ANSWER_SELECT_TEXT, - TYPE_RADIO => ANSWER_SELECT_TEXT, - TYPE_CHECKBOX => ANSWER_MULTI_SELECT - }.each do |type, expected_answer_type| - assert_equal( - expected_answer_type, - SelectQuestion.new(type: type, preserve_text: true).answer_type - ) - assert_equal( - expected_answer_type, - SelectQuestion.new(type: type, allow_other: true).answer_type - ) - end - end - - test 'get_value for single selection returns the numeric value' do + test 'get_value for single selection returns the single value' do question = SelectQuestion.new(id: 1, type: TYPE_RADIO, options: %w(First Second Third)) - assert_equal 1, question.get_value('First') - assert_equal 2, question.get_value('Second') - assert_equal 3, question.get_value('Third') + assert_equal 'First', question.get_value('First') + assert_equal 'Second', question.get_value('Second') + assert_equal 'Third', question.get_value('Third') e = assert_raises do question.get_value('Invalid') @@ -76,13 +59,6 @@ class SelectQuestionTest < ActiveSupport::TestCase assert_equal %w(Second Third), question.get_value(%w(Second Third)) end - test 'get_value with preserve_text' do - question = SelectQuestion.new(id: 1, options: %w(First Second Third), preserve_text: true) - - assert_equal 'First', question.get_value('First') - assert_equal %w(Second Third), question.get_value(%w(Second Third)) - end - test 'get_value with other' do question = SelectQuestion.new( id: 1, @@ -108,8 +84,7 @@ class SelectQuestionTest < ActiveSupport::TestCase order: 1, options: %w(One Two Three), allow_other: true, - other_text: 'Other', - preserve_text: false + other_text: 'Other' } question = SelectQuestion.new(hash) @@ -128,8 +103,8 @@ class SelectQuestionTest < ActiveSupport::TestCase expected_summary = { 'sampleSelect' => { text: 'a label', - answer_type: ANSWER_SELECT_VALUE, - max_value: 3 + answer_type: ANSWER_SINGLE_SELECT, + options: %w(One Two Three) } } assert_equal expected_summary, question.summarize diff --git a/dashboard/test/models/pd/international_opt_in_test.rb b/dashboard/test/models/pd/international_opt_in_test.rb new file mode 100644 index 0000000000000..e332d884a4b91 --- /dev/null +++ b/dashboard/test/models/pd/international_opt_in_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' + +class Pd::InternationalOptInTest < ActiveSupport::TestCase + FORM_DATA = { + firstName: 'First', + firstNamePreferred: 'Preferred', + lastName: 'Last', + email: 'foo@bar.com', + emailAlternate: 'footoo@bar.com', + gender: 'Prefer not to answer', + schoolName: 'School Name', + schoolCity: 'School City', + schoolCountry: 'School Country', + ages: ['19+ years old'], + subjects: ['ICT'], + resources: ['Kodable'], + robotics: ['LEGO Education'], + workshopOrganizer: 'Workshop Organizer', + workshopFacilitator: 'Workshop Facilitator', + workshopCourse: 'Workshop Course', + emailOptIn: 'Yes', + legalOptIn: true + } + + test 'Test international opt-in validation' do + teacher = create :teacher + + refute build(:pd_international_opt_in, form_data: {}.to_json, user_id: teacher.id).valid? + + refute build( + :pd_international_opt_in, form_data: FORM_DATA.merge({ages: nil}).to_json, user_id: teacher.id + ).valid? + + assert build(:pd_international_opt_in, form_data: FORM_DATA.to_json, user_id: teacher.id).valid? + + refute build(:pd_international_opt_in, form_data: FORM_DATA.to_json).valid? + end +end diff --git a/dashboard/test/models/user_test.rb b/dashboard/test/models/user_test.rb index 54de4f2c82126..4b1ac04ceec8b 100644 --- a/dashboard/test/models/user_test.rb +++ b/dashboard/test/models/user_test.rb @@ -1764,6 +1764,28 @@ def update_primary_contact_info_fails_safely_for(user, *params) assert_equal original_primary_contact_info, user.primary_contact_info end + test 'google_classroom_student? is true if user belongs to a google classroom section as a student' do + section = create(:section, login_type: Section::LOGIN_TYPE_GOOGLE_CLASSROOM) + user = create(:follower, section: section).student_user + assert user.google_classroom_student? + end + + test 'google_classroom_student? is false if user does not belong to any google classroom sections as a student' do + user = create(:user) + refute user.google_classroom_student? + end + + test 'clever_student? is true if user belongs to a clever section as a student' do + section = create(:section, login_type: Section::LOGIN_TYPE_CLEVER) + user = create(:follower, section: section).student_user + assert user.clever_student? + end + + test 'clever_student? is false if user does not belong to any clever sections as a student' do + user = create(:user) + refute user.clever_student? + end + test 'track_proficiency adds proficiency if necessary and no hint used' do level_concept_difficulty = create :level_concept_difficulty # Defaults with repeat_loops_{d1,d2,d3,d4,d5}_count = {0,2,0,3,0}. diff --git a/deployment.rb b/deployment.rb index 71a7f1988c5b9..a3d1f2ad08be6 100644 --- a/deployment.rb +++ b/deployment.rb @@ -11,6 +11,7 @@ require 'cdo/slog' require 'os' require 'cdo/aws/cdo_google_credentials' +require 'cdo/git_utils' def load_yaml_file(path) return nil unless File.file?(path) @@ -25,6 +26,22 @@ def load_languages(path) end end +# Since channel ids are derived from user id and other sequential integer ids +# use a new S3 sources directory for each Test Build to prevent a UI test +# from inadvertently using a channel id from a previous Test Build. +# CircleCI environments already override the sources_s3_directory setting to suffix it with the Circle Build number: +# https://github.com/code-dot-org/code-dot-org/blob/fb53af48ec0598692ed19f340f26d2ed0bd9547b/.circleci/config.yml#L153 +# Detect Circle environment just to be safe. +def sources_s3_dir(environment) + if environment == :production + 'sources' + elsif environment == :test && !ENV['CIRCLECI'] + "sources_#{environment}/#{GitUtils.git_revision_short}" + else + "sources_#{environment}" + end +end + def load_configuration root_dir = File.expand_path('..', __FILE__) root_dir = '/home/ubuntu/website-ci' if root_dir == '/home/ubuntu/Dropbox (Code.org)' @@ -107,7 +124,7 @@ def load_configuration 'assets_s3_bucket' => 'cdo-v3-assets', 'assets_s3_directory' => rack_env == :production ? 'assets' : "assets_#{rack_env}", 'sources_s3_bucket' => 'cdo-v3-sources', - 'sources_s3_directory' => rack_env == :production ? 'sources' : "sources_#{rack_env}", + 'sources_s3_directory' => sources_s3_dir(rack_env), 'use_pusher' => false, 'pusher_app_id' => 'fake_app_id', 'pusher_application_key' => 'fake_application_key', diff --git a/lib/cdo/delete_accounts_helper.rb b/lib/cdo/delete_accounts_helper.rb index aa32290331551..cd6472b131c5d 100644 --- a/lib/cdo/delete_accounts_helper.rb +++ b/lib/cdo/delete_accounts_helper.rb @@ -116,6 +116,7 @@ def clean_and_destroy_pd_content(user_id) Pd::FacilitatorProgramRegistration.where(user_id: user_id).each(&:clear_form_data) Pd::RegionalPartnerProgramRegistration.where(user_id: user_id).each(&:clear_form_data) Pd::WorkshopMaterialOrder.where(user_id: user_id).each(&:clear_data) + Pd::InternationalOptIn.where(user_id: user_id).each(&:clear_form_data) pd_enrollment_id = Pd::Enrollment.where(user_id: user_id).pluck(:id).first if pd_enrollment_id diff --git a/lib/cdo/email_preference_constants.rb b/lib/cdo/email_preference_constants.rb index 04357d226725e..dc39a810a5a85 100644 --- a/lib/cdo/email_preference_constants.rb +++ b/lib/cdo/email_preference_constants.rb @@ -15,6 +15,7 @@ module EmailPreferenceConstants FORM_HOC_SIGN_UP = 'FORM_HOC_SIGN_UP'.freeze, FORM_CLASS_SUBMIT = 'FORM_CLASS_SUBMIT'.freeze, FORM_PETITION = 'FORM_PETITION'.freeze, + FORM_PD_INTERNATIONAL_OPT_IN = 'FORM_PD_INTERNATIONAL_OPT_IN'.freeze, # A one-time automated script sets all Petition submissions younger than 16 and older than 13 to opted out to comply # with GDPR. AUTOMATED_OPT_OUT_UNDER_16 = 'AUTOMATED_OPT_OUT_UNDER_16'.freeze diff --git a/pegasus/data/cdo-team.csv b/pegasus/data/cdo-team.csv index 23b29cf64d7a8..2ddf0a37d681a 100644 --- a/pegasus/data/cdo-team.csv +++ b/pegasus/data/cdo-team.csv @@ -52,7 +52,7 @@ Marina Taylor,Director of Product,,"Marina leads the Product team at Code.org wh Mark Barrett,Product Designer,,"Mark designs delightful and intuitive user experiences. After 10 years in the games industry, he is excited to apply his creativity and experience to the improvement of computer science education. He has been a mentor with the Big Brothers Big Sisters program for 11 years, and volunteer teaches after-school workshops at different schools throughout the Seattle area.",team Megan Godwin,People Ops,,"Megan manages recruiting, hiring, onboarding, HR and employee development for the Code.org team. With a background in both startups and sales management, Megan is passionate about growing, supporting, and developing a successful team. ",team Mehal Shah,Software Engineer,,"Mehal is a software engineer who enjoys building big systems that help people learn. In his spare time, he enjoys hiking, photography, and competitive puzzle solving.",team -Min Yoo,Director of Marketing & Development,,Min oversees the marketing and fundraising teams at Code.org team. ,team +Min Yoo,Director of Marketing & Development,,"Min Yoo is the Director of Marketing and Development at Code.org. Initially intending to become a writer, Min took her first computer science class during her sophomore year at Stanford and graduated two years later with a computer science degree in hand. Min is an ardent believer in the foundational nature of computer science in education, regardless of eventual career path, and the need for equitable access, regardless of gender, race, or socioeconomic background.",team Nimisha Ghosh Roy,Outreach Program Manager,,"Nimisha is a project manager and educator with experience in teaching youth and adults in formal and informal environments, curriculum development with an equity lens, cross cultural communication and developing collaborative partnerships. She is a Seattle-native and is active in the local Indian dance/performing arts community.",team Pat Yongpradit,Chief Academic Officer,http://patyongpradit.com/,"Pat is a former high school computer science teacher who specialized in creating authentic, project-based learning experiences. He has worked as a curriculum consultant for Microsoft, for Pearson, and also as a curriculum team lead for Montgomery County Public Schools. At Code.org he is responsible for creating (or curating) curriculum and professional development, to provide rich computer science opportunities to every school in the nation.",team Paul Carduner,Software Engineer,,"Paul started programming in middle school after attending a summer camp where he learned to make games with LogoWriter and hasn't stopped programming since. In high school, he got his first taste of contributing to open source educational software by writing Guido van Robot, a pythonic version of Karel the Robot. Most recently, Paul spent 5 years at Facebook in both engineering and leadership capacities. He is thrilled at the opportunity to make the joys of computer science accessible to everyone.",team diff --git a/pegasus/sites.v3/advocacy.code.org/public/images/2018-state-policy-forum-one-pager.pdf b/pegasus/sites.v3/advocacy.code.org/public/images/2018-state-policy-forum-one-pager.pdf new file mode 100644 index 0000000000000..9ce7d2ca49f08 Binary files /dev/null and b/pegasus/sites.v3/advocacy.code.org/public/images/2018-state-policy-forum-one-pager.pdf differ diff --git a/pegasus/sites.v3/advocacy.code.org/public/state-policy-forum.md b/pegasus/sites.v3/advocacy.code.org/public/state-policy-forum.md index e1e8399cf5a90..037e5a5b158c0 100644 --- a/pegasus/sites.v3/advocacy.code.org/public/state-policy-forum.md +++ b/pegasus/sites.v3/advocacy.code.org/public/state-policy-forum.md @@ -8,7 +8,7 @@ video_player: true

    -## Join us September 27-28 2018 +## Join Us September 27-28, 2018 The annual State Policy Forum brings together legislators, education officials, state advocates, industry, national organizations, and representatives from state executive offices committed to K-12 computer science education. This year's forum is co-hosted by [Code.org](https://code.org/promote) and the Computer Science Teachers Association ([CSTA](https://www.csteachers.org/)). @@ -29,7 +29,7 @@ The **main forum** begins at 3:00 pm on Thursday, September 27 and concludes at -## Pictures from Previous Forums +## Pictures From Previous Forums
    Networking activity @@ -73,7 +73,7 @@ The pre-forum begins at 9:00 am on Thursday, September 27 as a prelude to the op The main forum begins at 3:00 pm on Thursday, September 27 following the pre-forum for Advocacy Coalition members and concludes at 4:00 pm on Friday, September 28. Sessions include: * The State of K-12 CS -* Flash Talks: Data and Accountability, Reaching Rural areas, Certification, Managing Grant Programs, State and Local Boards, Legislative Successes/Failures +* Flash Talks: Data and Accountability, Reaching Rural Areas, Certification, Managing Grant Programs, State and Local Boards, Legislative Successes/Failures * Workshops: Standards, State Plan, Legislation, Microcredentials, Advocacy * Birds of a Feather Discussions * Team Planning Time @@ -157,7 +157,7 @@ We encourage business casual attire during the forum. All our conference session **What devices will I need to bring?**


    -Please bring your own laptop/tablet, as we will be accessing online resource. We will not have extra devices on hand for participants to borrow. +Please bring your own laptop/tablet, as we will be accessing online resources. We will not have extra devices on hand for participants to borrow.

    @@ -166,7 +166,7 @@ Please bring your own laptop/tablet, as we will be accessing online resource. We ## Forum Resources * [2018 Code.org/CSTA State Policy Forum one-pager](https://advocacy.code.org/2018-state-policy-forum-one-pager.pdf) -* [State Policy Tracker](https://docs.google.com/spreadsheets/d/1YtTVcpQXoZz0IchihwGOihaCNeqCz2HyLwaXYpyb2SQ/edit?usp=sharing) +* [State Policy Tracker](http://bit.ly/9policies) diff --git a/pegasus/sites.v3/code.org/public/about/leadership/min_yoo.md b/pegasus/sites.v3/code.org/public/about/leadership/min_yoo.md index 0e0672f1c0159..3201d675b1487 100644 --- a/pegasus/sites.v3/code.org/public/about/leadership/min_yoo.md +++ b/pegasus/sites.v3/code.org/public/about/leadership/min_yoo.md @@ -8,3 +8,8 @@ theme: responsive '/ width='320px'>

    +Min Yoo is the Director of Marketing and Development at Code.org. Her teams are responsible for building awareness of the need for equitable computer science education, breaking stereotypes surrounding computer science, raising funds, and managing relationships with supporters who enable Code.org to work towards our mission. + +Initially intending to become a writer, Min took her first computer science class during her sophomore year at Stanford and graduated two years later with a computer science degree in hand. She has spent over 25 years in roles spanning product management, marketing, and business development at enterprise companies like Oracle to tech startups to her own consulting business. + +Min is an ardent believer in the foundational nature of computer science in education, regardless of eventual career path, and the need for equitable access, regardless of gender, race, or socioeconomic background. \ No newline at end of file diff --git a/pegasus/sites.v3/code.org/public/help.md b/pegasus/sites.v3/code.org/public/help.md index 42d58b9bdd62d..21c3b2e93fe18 100644 --- a/pegasus/sites.v3/code.org/public/help.md +++ b/pegasus/sites.v3/code.org/public/help.md @@ -4,7 +4,7 @@ theme: responsive style_min: true --- -# How to Help +# How to Help! ## Ask your school to teach computer science Encourage your local school to start teaching computer science. To make it easier, Code.org offers [courses for every grade level](https://studio.code.org/courses?view=teacher) from kindergarten through high school at no cost. And, teachers can enroll in our [hands-on professional learning workshops](/educate/professional-learning) offered locally across the United States. diff --git a/pegasus/sites.v3/code.org/views/workshop_affiliates/1753728_bio.md b/pegasus/sites.v3/code.org/views/workshop_affiliates/1753728_bio.md index 19c4f6b0429a4..e16e97547a140 100644 --- a/pegasus/sites.v3/code.org/views/workshop_affiliates/1753728_bio.md +++ b/pegasus/sites.v3/code.org/views/workshop_affiliates/1753728_bio.md @@ -1,5 +1,5 @@ ## Hope Yamada -[hope.yamada@cvesd.org](mailto:hope.yamada@cvesd.org) +[hyamada619@gmail.com](mailto:hyamada619@gmail.com) -Hope began her career in IT as a System Analyst for a large healthcare organization. She was responsible for the installation, training, upgrading, and maintenance of the Patient Accounting/Billing software. After her sons were born, she began teaching –and continues to do so--in the Computer Information Systems department for a local junior college. Several years later, she was asked to teach tech classes at her sons school (K-8). During the same time, she took on the additional role as a PD instructor for Beyond Technology-- training teachers and staff on the G Suite of apps as well as Microsoft applications. For the last few years, she has worked as a technology support specialist/technology instructor for a local K-8 charter school. She has piloted Microsoft Classroom, and will also be teaching Minecraft as well as CS Discoveries in her tech courses in the upcoming year. +Hope began her career in IT as a System Analyst for a large healthcare organization. She was responsible for the installation, training, upgrading, and maintenance of the Patient Accounting/Billing software. After her sons were born, she began teaching –and continues to do so--in the Computer Information Systems department for a local junior college. Several years later, she was asked to teach tech classes at her sons school (K-8). During the same time, she took on the additional role as a PD instructor for Beyond Technology-- training teachers and staff on the G Suite of apps as well as Microsoft applications. For the last few years, she has worked as a technology support specialist/technology instructor for a local K-8 charter school. She has piloted Microsoft Classroom, and will also be teaching Minecraft as well as CS Discoveries in her tech courses in the upcoming year. diff --git a/shared/test/common_test_helper.rb b/shared/test/common_test_helper.rb index b18e09b56f95f..bde43e73d82a2 100644 --- a/shared/test/common_test_helper.rb +++ b/shared/test/common_test_helper.rb @@ -69,6 +69,15 @@ def around(&block) any_instance. stubs(:static_credentials). returns(credentials) + + # CDO.sources_s3_directory contains the commit hash when running in the test + # environment, so new projects created during UI tests will not already + # contain source code generated from previous test runs. However, this is + # not compatible with our unit tests which use VCR to stub out network + # requests to url paths which must be consistent across test runs. + # Therefore, remove the commit-specific part of this path only in unit tests. + CDO.stubs(:sources_s3_directory).returns('sources_test') + VCR.use_cassette(cassette_name, record: record_mode) do PEGASUS_DB.transaction(rollback: :always) do AWS::S3.stub(:random, proc {random.bytes(16).unpack('H*')[0]}, &block) diff --git a/shared/test/test_sources.rb b/shared/test/test_sources.rb index 21baa918ffe8e..d190c984490b3 100644 --- a/shared/test/test_sources.rb +++ b/shared/test/test_sources.rb @@ -539,7 +539,7 @@ def test_remix_source_file delete_all_animation_versions(animation_filename_1) delete_all_animation_versions(animation_filename_2) - delete_all_versions(CDO.sources_s3_bucket, "sources_test/1/2/#{MAIN_JSON}") + delete_all_versions(CDO.sources_s3_bucket, "#{CDO.sources_s3_directory}/1/2/#{MAIN_JSON}") delete_all_versions(CDO.animations_s3_bucket, "animations_test/1/2/#{animation_filename_1}") delete_all_versions(CDO.animations_s3_bucket, "animations_test/1/2/#{animation_filename_2}") end @@ -583,7 +583,7 @@ def test_remix_source_file_with_library_animations # Clear original and remixed buckets delete_all_source_versions(MAIN_JSON) - delete_all_versions(CDO.sources_s3_bucket, "sources_test/1/2/#{MAIN_JSON}") + delete_all_versions(CDO.sources_s3_bucket, "#{CDO.sources_s3_directory}/1/2/#{MAIN_JSON}") end def test_remix_not_main @@ -610,7 +610,7 @@ def test_remix_not_main # Clear original and remixed buckets delete_all_source_versions('test.json') - delete_all_versions(CDO.sources_s3_bucket, "sources_test/1/2/test.json") + delete_all_versions(CDO.sources_s3_bucket, "#{CDO.sources_s3_directory}/1/2/test.json") end def test_remix_no_animations @@ -642,7 +642,7 @@ def test_remix_no_animations # Clear original and remixed buckets delete_all_source_versions(MAIN_JSON) - delete_all_versions(CDO.sources_s3_bucket, "sources_test/1/2/#{MAIN_JSON}") + delete_all_versions(CDO.sources_s3_bucket, "#{CDO.sources_s3_directory}/1/2/#{MAIN_JSON}") end def test_remove_under_13_comments @@ -818,7 +818,7 @@ def restore_main_json(version_id) end def delete_all_source_versions(filename) - delete_all_versions(CDO.sources_s3_bucket, "sources_test/1/1/#{filename}") + delete_all_versions(CDO.sources_s3_bucket, "#{CDO.sources_s3_directory}/1/1/#{filename}") end def delete_all_animation_versions(filename)