diff --git a/src/components/Groups/GroupExamStatus/GroupExamStatus.js b/src/components/Groups/GroupExamStatus/GroupExamStatus.js new file mode 100644 index 000000000..ff6ef5b7a --- /dev/null +++ b/src/components/Groups/GroupExamStatus/GroupExamStatus.js @@ -0,0 +1,338 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Modal } from 'react-bootstrap'; + +import ExamForm, { + prepareInitValues as prepareExamInitValues, + transformSubmittedData as transformExamData, +} from '../../forms/ExamForm'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import Callout from '../../widgets/Callout'; +import Icon, { BanIcon, ClockIcon, EditIcon, GroupExamsIcon, LoadingIcon } from '../../icons'; +import DateTime from '../../widgets/DateTime'; +import Explanation from '../../widgets/Explanation'; +import { getErrorMessage } from '../../../locales/apiErrorMessages'; + +import { hasPermissions } from '../../../helpers/common'; + +const REFRESH_INTERVAL = 1; // [s] + +class GroupExamStatus extends Component { + state = { examModal: false }; + intervalHandler = null; + + static getDerivedStateFromProps({ group }, state) { + const now = Date.now() / 1000; + const hasExam = group.privateData.examBegin && group.privateData.examEnd && group.privateData.examEnd > now; + const examInProgress = hasExam && group.privateData.examBegin <= now; + const examEndsIn24 = hasExam && group.privateData.examEnd < now + 86400; + const nextChange = examInProgress + ? group.privateData.examEnd - now + : hasExam + ? group.privateData.examBegin - now + : null; + const changeImminent = nextChange && nextChange <= 5; // s + const examModal = (state.examModal && !changeImminent) || false; + return { hasExam, examInProgress, changeImminent, examEndsIn24, examModal }; + } + + examModalOpen = () => { + this.setState({ examModal: true }); + }; + + examModalClose = () => { + this.setState({ examModal: false }); + }; + + examFormSubmit = data => { + const { begin, end, strict } = transformExamData(data); + const { examInProgress } = this.state; + return this.props.setExamPeriod(examInProgress ? null : begin, end, examInProgress ? null : strict).then(res => { + this.examModalClose(); + return Promise.resolve(res); + }); + }; + + removeExam = () => { + const { + removeExamPeriod, + addNotification, + intl: { formatMessage }, + } = this.props; + addNotification('kuk'); + removeExamPeriod().catch(err => { + addNotification(getErrorMessage(formatMessage)(err), false); + }); + }; + + startNow = () => { + const { + group, + setExamPeriod, + addNotification, + intl: { formatMessage }, + } = this.props; + setExamPeriod(Math.round(Date.now() / 1000), group.privateData.examEnd).catch(err => { + addNotification(getErrorMessage(formatMessage)(err), false); + }); + }; + + terminateNow = () => { + const { + setExamPeriod, + addNotification, + intl: { formatMessage }, + } = this.props; + setExamPeriod(null, Math.round(Date.now() / 1000)).catch(err => { + addNotification(getErrorMessage(formatMessage)(err), false); + }); + }; + + periodicRefresh = () => { + this.setState(GroupExamStatus.getDerivedStateFromProps(this.props, this.state)); + // console.log(this.state); + }; + + componentDidMount() { + if (window && 'setInterval' in window) { + if (this.intervalHandler) { + window.clearInterval(this.intervalHandler); + } + this.intervalHandler = window.setInterval(this.periodicRefresh, REFRESH_INTERVAL * 1000); + } + } + + componentWillUnmount() { + if (this.intervalHandler) { + window.clearInterval(this.intervalHandler); + this.intervalHandler = null; + } + } + + render() { + const { group, examBeginImmediately, examEndRelative, pending, removeExamPeriod } = this.props; + + return ( + <> + + ) : this.state.hasExam ? ( + + ) : null + }> +

+ {this.state.examInProgress ? ( + + ) : this.state.hasExam ? ( + + ) : ( + + )} +

+ + {this.state.hasExam && ( + + + + + + + + + + + + + + + +
+ : + + +
+ : + + +
+ : + + + {group.privateData.examLockStrict ? ( + + ) : ( + + )} + + + ) : ( + + ) + }> + {group.privateData.examLockStrict ? ( + + ) : ( + + )} + +
+ )} + + {hasPermissions(group, 'setExamPeriod') && ( + <> +
+ + + {this.state.hasExam ? ( + + ) : ( + + )} + + {this.state.examInProgress ? ( + + ) : ( + this.state.hasExam && ( + <> + {this.state.examEndsIn24 && ( + + )} + {hasPermissions(group, 'removeExamPeriod') && ( + + )} + + ) + )} + + + )} +
+ + {hasPermissions(group, 'setExamPeriod') && ( + + + + {this.state.hasExam ? ( + + ) : ( + + )} + + + + + + + + )} + + ); + } +} + +GroupExamStatus.propTypes = { + group: PropTypes.shape({ + privateData: PropTypes.shape({ + examBegin: PropTypes.number, + examEnd: PropTypes.number, + examLockStrict: PropTypes.bool, + }).isRequired, + }).isRequired, + examBeginImmediately: PropTypes.bool, + examEndRelative: PropTypes.bool, + pending: PropTypes.bool, + setExamPeriod: PropTypes.func.isRequired, + removeExamPeriod: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, + intl: PropTypes.object, +}; + +export default injectIntl(GroupExamStatus); diff --git a/src/components/Groups/GroupExamStatus/index.js b/src/components/Groups/GroupExamStatus/index.js new file mode 100644 index 000000000..b3a1873a0 --- /dev/null +++ b/src/components/Groups/GroupExamStatus/index.js @@ -0,0 +1,2 @@ +import GroupExamStatus from './GroupExamStatus'; +export default GroupExamStatus; diff --git a/src/components/forms/ExamForm/ExamForm.js b/src/components/forms/ExamForm/ExamForm.js new file mode 100644 index 000000000..c530bb54a --- /dev/null +++ b/src/components/forms/ExamForm/ExamForm.js @@ -0,0 +1,317 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { reduxForm, Field } from 'redux-form'; +import { Container, Row, Col, Form } from 'react-bootstrap'; +import moment from 'moment'; + +import Callout from '../../widgets/Callout'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import Explanation from '../../widgets/Explanation'; +import SubmitButton from '../SubmitButton'; +import { CloseIcon } from '../../icons'; + +import { TextField, CheckboxField, DatetimeField } from '../Fields'; + +export const secondsToTime = seconds => { + if (seconds < 0) { + return ''; + } + let minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + minutes -= hours * 60; + return `${hours}:${minutes.toString().padStart(2, '0')}`; +}; + +export const timeToSeconds = timeStr => { + const res = timeStr.match(/^([0-9]+):([0-9]{2})$/); + if (!res || res.length !== 3) { + return null; + } + + const hours = parseInt(res[1]); + const minutes = parseInt(res[2]); + return !isNaN(hours) && !isNaN(minutes) ? hours * 3600 + minutes * 60 : null; +}; + +export const prepareInitValues = (begin = null, end = null, strict = false) => ({ + beginImmediately: false, + endRelative: false, + begin: begin ? moment.unix(begin) : moment().add(2, 'hour').startOf('hour'), + end: end ? moment.unix(end) : begin ? moment.unix(begin).add(2, 'hour') : moment().add(4, 'hour').startOf('hour'), + strict, + length: begin && end ? secondsToTime(end - begin) : '2:00', +}); + +export const transformSubmittedData = ({ beginImmediately, endRelative, begin, end, length, strict = false }) => { + const beginTs = beginImmediately ? Math.ceil(Date.now() / 1000) : moment.isMoment(begin) ? begin.unix() : null; + const endTs = endRelative + ? beginTs && timeToSeconds(length) + ? beginTs + timeToSeconds(length) + : null + : moment.isMoment(end) + ? end.unix() + : null; + return { begin: beginTs, end: endTs, strict }; +}; + +const ExamForm = ({ + error, + submitting, + handleSubmit, + onSubmit, + dirty = false, + submitFailed = false, + submitSucceeded = false, + invalid, + reset, + beginImmediately = false, + endRelative = false, + createNew = false, + examInProgress = false, + onCancel = null, +}) => ( +
+ {submitFailed && ( + + + + )} + + + + + {examInProgress ? ( + + + + ) : ( + <> + } + /> + + {!beginImmediately && ( + + + + } + /> + )} + + )} + + + + + + + + + + } + /> + + {endRelative ? ( + } + /> + ) : ( + } + /> + )} + + + {!examInProgress && ( + + +
+ + + + + + + } + /> + +
+ )} +
+ + {error && dirty && {error}} + +
+
+ + onSubmit(data).then(reset))} + submitting={submitting} + dirty={dirty} + hasSucceeded={submitSucceeded} + hasFailed={submitFailed} + invalid={invalid} + messages={{ + submit: createNew ? ( + + ) : ( + + ), + submitting: , + success: , + }} + /> + {onCancel && ( + + )} + +
+
+); + +ExamForm.propTypes = { + error: PropTypes.any, + handleSubmit: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + dirty: PropTypes.bool, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + submitting: PropTypes.bool, + invalid: PropTypes.bool, + reset: PropTypes.func, + onCancel: PropTypes.func, + createNew: PropTypes.bool, + beginImmediately: PropTypes.bool, + endRelative: PropTypes.bool, + examInProgress: PropTypes.bool, +}; + +const validate = ({ beginImmediately, begin, endRelative, end, length }, { examInProgress, initialValues }) => { + const errors = {}; + const tolerance = 60; + const now = Math.ceil(Date.now() / 1000); + + // check begin time and save its unix ts + let beginTs = null; + if (!examInProgress) { + if (!beginImmediately) { + if (!moment.isMoment(begin) || begin.unix() < now + tolerance) { + errors.begin = ( + + ); + } else { + beginTs = begin.unix(); + } + } else { + beginTs = now; + } + } else { + beginTs = initialValues.begin.unix(); + } + + // check end time and save its unix ts + let endTs = null; + if (endRelative) { + const lengthSec = timeToSeconds(length); + if (!lengthSec) { + errors.length = ( + + ); + } else { + endTs = beginTs && beginTs + lengthSec; + } + } else { + if (!moment.isMoment(end)) { + errors.end = ( + + ); + } else { + endTs = end.unix(); + } + } + + // end must be in the future + if (endTs && endTs < now + tolerance) { + errors[endRelative ? 'length' : 'end'] = ( + + ); + } + + // check the end relative to beginning + if (beginTs && endTs) { + if (beginTs >= endTs) { + errors.end = ( + + ); + } + + if (endTs - beginTs > 86400) { + errors[endRelative ? 'length' : 'end'] = ( + + ); + } + } + + return errors; +}; + +export default reduxForm({ + enableReinitialize: true, + keepDirtyOnReinitialize: false, + validate, +})(ExamForm); diff --git a/src/components/forms/ExamForm/index.js b/src/components/forms/ExamForm/index.js new file mode 100644 index 000000000..b90c97440 --- /dev/null +++ b/src/components/forms/ExamForm/index.js @@ -0,0 +1,3 @@ +import ExamForm from './ExamForm'; +export default ExamForm; +export { prepareInitValues, transformSubmittedData } from './ExamForm'; diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 054c9f7c6..0c960ff34 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -31,6 +31,7 @@ export { CheckRequiredIcon }; export const CircleIcon = ({ selected = false, ...props }) => ( ); +export const ClockIcon = props => ; export const CloseIcon = props => ; export const CodeFileIcon = props => ; export const CodeIcon = props => ; @@ -64,6 +65,7 @@ export const ForkIcon = props => ; export const GroupIcon = ({ organizational = false, archived = false, exam = false, ...props }) => ( ); +export const GroupExamsIcon = props => ; export const HomeIcon = props => ; export const InfoIcon = props => ; export const InputIcon = props => ; diff --git a/src/components/layout/Navigation/GroupNavigation.js b/src/components/layout/Navigation/GroupNavigation.js index 6cb8ace85..e805f1371 100644 --- a/src/components/layout/Navigation/GroupNavigation.js +++ b/src/components/layout/Navigation/GroupNavigation.js @@ -6,41 +6,49 @@ import Navigation from './Navigation'; import withLinks from '../../../helpers/withLinks'; import { createGroupLinks } from './linkCreators'; import { MailIcon, UserIcon, UserProfileIcon } from '../../icons'; +import { hasPermissions, hasOneOfPermissions } from '../../../helpers/common'; -const GroupNavigation = ({ groupId, userId = null, canViewDetail = false, canEdit = false, emails = null, links }) => ( - , - link: links.GROUP_USER_SOLUTIONS_URI_FACTORY(groupId, userId), - icon: , - }, - ...createGroupLinks(links, groupId, canViewDetail, canEdit), - ]} - secondaryLinks={[ - emails && { - caption: , - href: `mailto:?bcc=${emails}`, - icon: , - }, - userId && - canEdit && { - caption: , - link: links.USER_URI_FACTORY(userId), - icon: , +const GroupNavigation = ({ group, userId = null, emails = null, links }) => { + const canEdit = hasOneOfPermissions(group, 'update', 'archive', 'remove', 'relocate'); + const canViewDetail = hasPermissions(group, 'viewDetail'); + const canSeeExams = + hasOneOfPermissions(group, 'setExamPeriod', 'removeExamPeriod') || + group.privateData.exams?.length > 0 || + group.privateData.examBegin; + + return ( + , + link: links.GROUP_USER_SOLUTIONS_URI_FACTORY(group.id, userId), + icon: , + }, + ...createGroupLinks(links, group.id, canViewDetail, canEdit, canSeeExams), + ]} + secondaryLinks={[ + emails && { + caption: , + href: `mailto:?bcc=${emails}`, + icon: , }, - ]} - /> -); + userId && + canEdit && { + caption: , + link: links.USER_URI_FACTORY(userId), + icon: , + }, + ]} + /> + ); +}; GroupNavigation.propTypes = { - groupId: PropTypes.string.isRequired, + group: PropTypes.object.isRequired, userId: PropTypes.string, - canViewDetail: PropTypes.bool, - canEdit: PropTypes.bool, emails: PropTypes.string, links: PropTypes.object.isRequired, }; diff --git a/src/components/layout/Navigation/linkCreators.js b/src/components/layout/Navigation/linkCreators.js index b20871ce3..13a61f001 100644 --- a/src/components/layout/Navigation/linkCreators.js +++ b/src/components/layout/Navigation/linkCreators.js @@ -4,6 +4,7 @@ import { AssignmentsIcon, EditIcon, GroupIcon, + GroupExamsIcon, ExerciseIcon, LimitsIcon, ReferenceSolutionIcon, @@ -12,10 +13,17 @@ import { } from '../../icons'; export const createGroupLinks = ( - { GROUP_INFO_URI_FACTORY, GROUP_ASSIGNMENTS_URI_FACTORY, GROUP_STUDENTS_URI_FACTORY, GROUP_EDIT_URI_FACTORY }, + { + GROUP_INFO_URI_FACTORY, + GROUP_ASSIGNMENTS_URI_FACTORY, + GROUP_STUDENTS_URI_FACTORY, + GROUP_EDIT_URI_FACTORY, + GROUP_EXAMS_URI_FACTORY, + }, groupId, canViewDetail = true, - canEdit = false + canEdit = false, + canSeeExams = false ) => [ { caption: , @@ -37,6 +45,11 @@ export const createGroupLinks = ( link: GROUP_EDIT_URI_FACTORY(groupId), icon: , }, + canSeeExams && { + caption: , + link: GROUP_EXAMS_URI_FACTORY(groupId), + icon: , + }, ]; export const createExerciseLinks = ( diff --git a/src/locales/cs.json b/src/locales/cs.json index ff2df69c4..a6357000b 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -716,6 +716,7 @@ "app.evaluationTable.evaluationIsDebug": "Vyhodnoceno v ladícím módu (kompletní logy a výpisy)", "app.evaluationTable.notAvailable": "Vyhodnocení není dostupné", "app.evaluationTable.score": "Skóre:", + "app.examForm.alreadyStarted": "Zkouška již začala...", "app.examForm.beginImmediately": "Zahájit okamžitě", "app.examForm.end": "Konec:", "app.examForm.endRelative": "Nastavit délku (místo času konce)", @@ -728,6 +729,8 @@ "app.examForm.errors.tooLongExam": "Zkouška nesmí trvat déle než 24 hodin.", "app.examForm.length": "Délka [h:mm]:", "app.examForm.saveExam": "Uložit zkoušku", + "app.examForm.strict": "Striktní zámek", + "app.examForm.strictLockExplanation": "Během zkoušky se studenti musí zamknout ve skupině, přičemž se jim omezí přístup k ostatním skupinám. V případě běžného zámku budou ostatní skupiny přístupné pouze pro čtení. Striktní zámek zakáže přístup do ostatních skupin zcela. Pokud si nepřejete, aby studenti mohli používat části zdrojových kódu dříve odevzdaných řešení, zvolte striktní zámek.", "app.exercise.addReferenceSolutionDetailed": "Referenční řešení můžete vytvořit na hlavní stránce úlohy.", "app.exercise.admins": "Administrátoři", "app.exercise.admins.explanation": "Administrátoři mají stejná práva jako autor úlohy, ale nejsou zobrazováni v seznamech ani nejsou použiti při filtrování úloh.", @@ -1049,18 +1052,31 @@ "app.groupDetail.supervisors": "Vedoucí skupiny {groupName}", "app.groupDetail.threshold": "Minimální procentuální počet bodů potřebných ke splnění tohoto kurzu", "app.groupDetail.title": "Zadané úlohy", - "app.groupExams.createExamExplain": "The exam group works like a regular group, but it has additional security features. The students does not see the assignments until they lock them selves in. The students can lock in only during the exam and will be unlocked afterwards. A locked student may access the system from a single IP address and may not visit any other groups.", - "app.groupExams.examButton": "Create Exam", - "app.groupExams.examGroupCreateTitle": "Change to an examination group", - "app.groupExams.examGroupTitle": "Examination group", - "app.groupExams.examModal.create": "Plan an examination in this group", - "app.groupExams.removeExam": "Cancel examination", - "app.groupExams.removeExamExplain": "TODO", - "app.groupExams.title": "Change Group Settings", - "app.groupExams.truncateExam": "End Now", - "app.groupExams.truncateExamExplain": "TODO", - "app.groupExams.updateExam": "Update", - "app.groupExams.updateExamExplain": "TODO", + "app.groupExams.beginAt": "Začíná v", + "app.groupExams.button.cancel": "Zrušit zkoušku", + "app.groupExams.button.cancel.confirm": "Opravdu si přejete zrušit zkoušku?", + "app.groupExams.button.createNew": "Naplánovat novou zkoušku", + "app.groupExams.button.edit": "Upravit zkoušku", + "app.groupExams.button.start": "Zahájit nyní", + "app.groupExams.button.start.confirm": "Opravdu si přejete zahájit zkoušku okamžitě?", + "app.groupExams.button.terminate": "Ukončit nyní", + "app.groupExams.button.terminate.confirm": "Opravdu si přejete okamžitě ukončit probíhající zkoušku?", + "app.groupExams.endAt": "Končí v", + "app.groupExams.examModal.create": "Naplánovat zkouškový termín v této skupině", + "app.groupExams.examModal.edit": "Upravit naplánovaný termín v této skupině", + "app.groupExams.examPlanned": "Je naplánovaný zkouškový termín", + "app.groupExams.inProgress": "Probíhá zkouška, skupina je v zabezpečeném režimu", + "app.groupExams.listBoxTitle": "Předchozí zkoušky", + "app.groupExams.lockRegular": "běžný", + "app.groupExams.lockRegularExplanation": "Studenti, kteří se účastní zkoušky, budou moci číst data z ostatních skupin (a tedy i použít části dříve odevzdaných řešení).", + "app.groupExams.lockRegularTitle": "Běžný zámek", + "app.groupExams.lockStrict": "striktní", + "app.groupExams.lockStrictExplanation": "Studenti, kteří se účastní zkoušky, nebudou moct přistupovat do jiných skupin ani v režimu pro čtení (jsou tedy odříznuti od jejich dříve odevzdaných řešení).", + "app.groupExams.lockStrictTitle": "Striktní zámek", + "app.groupExams.locking": "Typ zámku", + "app.groupExams.noExam": "V tuto chvíli není naplánovaná žádná zkouška", + "app.groupExams.noPreviousExams": "Dosud nebyly žádné zkouškové termíny.", + "app.groupExams.title": "Zkouškové termíny skupiny", "app.groupInfo.title": "Podrobnosti a metadata skupiny", "app.groupInvitationForm.expireAt": "Konec platnosti:", "app.groupInvitationForm.expireAtExplanation": "Odkaz pozvánky bude rozpoznatelný ReCodExem i po uplynutí doby platnosti, ale studenti jej nebudou moci použít.", @@ -1228,6 +1244,7 @@ "app.navigation.exerciseTests": "Testy", "app.navigation.group": "Skupina", "app.navigation.groupAssignments": "Úlohy ve skupině", + "app.navigation.groupExams": "Zkouškové termíny", "app.navigation.groupInfo": "Info skupiny", "app.navigation.groupStudents": "Studenti ve skupině", "app.navigation.pipeline": "Pipeline", diff --git a/src/locales/en.json b/src/locales/en.json index 13967efce..e7e1c7f94 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -716,6 +716,7 @@ "app.evaluationTable.evaluationIsDebug": "Evaluated in debug mode (complete logs and dumps)", "app.evaluationTable.notAvailable": "Evaluation not available", "app.evaluationTable.score": "Score:", + "app.examForm.alreadyStarted": "The exam has already started...", "app.examForm.beginImmediately": "Begin immediately", "app.examForm.end": "End:", "app.examForm.endRelative": "Set length (instead of explicit end)", @@ -728,6 +729,8 @@ "app.examForm.errors.tooLongExam": "The exam must not be longer than 24 hours.", "app.examForm.length": "Length [h:mm]:", "app.examForm.saveExam": "Save Exam", + "app.examForm.strict": "Strict lock", + "app.examForm.strictLockExplanation": "During the exam, students will be required to lock themselves in the group. When locked, access to all other groups is restricted. In case of regular locks, other groups are read-only. If the lock is strict, the groups may not be accessed at all. Use strict locking when the students are to be prevented from utilizing pieced of previously submitted code.", "app.exercise.addReferenceSolutionDetailed": "A reference solution can be added on the exercise detail page.", "app.exercise.admins": "Administrators", "app.exercise.admins.explanation": "The administrators have the same permissions as the author towards the exercise, but they are not explicitly mentioned in listings or used in search filters.", @@ -1049,18 +1052,31 @@ "app.groupDetail.supervisors": "Supervisors of {groupName}", "app.groupDetail.threshold": "Minimum percent of the total points count needed to complete the course", "app.groupDetail.title": "Group Assignments", - "app.groupExams.createExamExplain": "The exam group works like a regular group, but it has additional security features. The students does not see the assignments until they lock them selves in. The students can lock in only during the exam and will be unlocked afterwards. A locked student may access the system from a single IP address and may not visit any other groups.", - "app.groupExams.examButton": "Create Exam", - "app.groupExams.examGroupCreateTitle": "Change to an examination group", - "app.groupExams.examGroupTitle": "Examination group", + "app.groupExams.beginAt": "Begins at", + "app.groupExams.button.cancel": "Cancel Exam", + "app.groupExams.button.cancel.confirm": "Do you really wish to cancel the scheduled exam?", + "app.groupExams.button.createNew": "Schedule New Exam", + "app.groupExams.button.edit": "Edit Exam", + "app.groupExams.button.start": "Start Now", + "app.groupExams.button.start.confirm": "Do you really wish to start the exam immediately?", + "app.groupExams.button.terminate": "Terminate Now", + "app.groupExams.button.terminate.confirm": "Do you really wish to terminate the exam immediately?", + "app.groupExams.endAt": "Ends at", "app.groupExams.examModal.create": "Plan an examination in this group", - "app.groupExams.removeExam": "Cancel examination", - "app.groupExams.removeExamExplain": "TODO", - "app.groupExams.title": "Change Group Settings", - "app.groupExams.truncateExam": "End Now", - "app.groupExams.truncateExamExplain": "TODO", - "app.groupExams.updateExam": "Update", - "app.groupExams.updateExamExplain": "TODO", + "app.groupExams.examModal.edit": "Update scheduled examination in this group", + "app.groupExams.examPlanned": "There is an exam scheduled", + "app.groupExams.inProgress": "Exam in progress, the group is in secured mode", + "app.groupExams.listBoxTitle": "Previous exams", + "app.groupExams.lockRegular": "regular", + "app.groupExams.lockRegularExplanation": "Users taking the exam will be able to access other groups in read-only mode (for instance to utilize pieces of previously submitted code).", + "app.groupExams.lockRegularTitle": "Regular lock", + "app.groupExams.lockStrict": "strict", + "app.groupExams.lockStrictExplanation": "Users taking the exam will not be allowed to access any other group, not even for reading (so thet are cut of source codes they submitted before the exam).", + "app.groupExams.lockStrictTitle": "Strict lock", + "app.groupExams.locking": "Lock type", + "app.groupExams.noExam": "There is currently no exam scheduled", + "app.groupExams.noPreviousExams": "There are no previous exams recorded.", + "app.groupExams.title": "Group Exam Terms", "app.groupInfo.title": "Group Details and Metadata", "app.groupInvitationForm.expireAt": "Expire at:", "app.groupInvitationForm.expireAtExplanation": "An invitation link will be still recognized by ReCodEx after the expiration date, but the students will not be allowed to use it.", @@ -1228,6 +1244,7 @@ "app.navigation.exerciseTests": "Tests", "app.navigation.group": "Group", "app.navigation.groupAssignments": "Group Assignments", + "app.navigation.groupExams": "Exam Terms", "app.navigation.groupInfo": "Group Info", "app.navigation.groupStudents": "Group Students", "app.navigation.pipeline": "Pipeline", diff --git a/src/pages/EditGroup/EditGroup.js b/src/pages/EditGroup/EditGroup.js index ccf1bd439..87d97f558 100644 --- a/src/pages/EditGroup/EditGroup.js +++ b/src/pages/EditGroup/EditGroup.js @@ -88,11 +88,7 @@ class EditGroup extends Component { title={}> {group => (
- + {!hasOneOfPermissions(group, 'update', 'archive', 'remove', 'relocate') && ( diff --git a/src/pages/GroupAssignments/GroupAssignments.js b/src/pages/GroupAssignments/GroupAssignments.js index bba40e5e5..394f77b6e 100644 --- a/src/pages/GroupAssignments/GroupAssignments.js +++ b/src/pages/GroupAssignments/GroupAssignments.js @@ -159,12 +159,7 @@ class GroupAssignments extends Component { return (
- + {canLeaveGroup && (
diff --git a/src/pages/GroupExams/GroupExams.js b/src/pages/GroupExams/GroupExams.js new file mode 100644 index 000000000..204a4381c --- /dev/null +++ b/src/pages/GroupExams/GroupExams.js @@ -0,0 +1,131 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import { Row, Col } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import { formValueSelector } from 'redux-form'; + +import Page from '../../components/layout/Page'; +import { GroupNavigation } from '../../components/layout/Navigation'; +import Box from '../../components/widgets/Box'; +import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning'; +import { GroupExamsIcon } from '../../components/icons'; + +import { fetchGroup, fetchGroupIfNeeded, setExamPeriod, removeExamPeriod } from '../../redux/modules/groups'; +import { addNotification } from '../../redux/modules/notifications'; +import { groupSelector, groupDataAccessorSelector, groupTypePendingChange } from '../../redux/selectors/groups'; +import { loggedInUserIdSelector } from '../../redux/selectors/auth'; +import { isLoggedAsSuperAdmin } from '../../redux/selectors/users'; + +import withLinks from '../../helpers/withLinks'; +import GroupExamStatus from '../../components/Groups/GroupExamStatus'; + +class GroupExams extends Component { + componentDidMount() { + this.props.loadAsync(); + } + + componentDidUpdate(prevProps) { + if (this.props.params.groupId !== prevProps.params.groupId) { + this.props.loadAsync(); + } + } + + render() { + const { + group, + groupsAccessor, + examBeginImmediately, + examEndRelative, + setExamPeriod, + removeExamPeriod, + addNotification, + links: { GROUP_EDIT_URI_FACTORY }, + } = this.props; + + return ( + } + title={}> + {group => ( +
+ + + + + + + + + + + }> + {group.privateData.exams && group.privateData.exams.length > 0 ? null : ( +
+ + + +
+ )} +
+ +
+
+ )} +
+ ); + } +} + +GroupExams.propTypes = { + links: PropTypes.object.isRequired, + loadAsync: PropTypes.func.isRequired, + reload: PropTypes.func.isRequired, + params: PropTypes.shape({ + groupId: PropTypes.string.isRequired, + }).isRequired, + group: ImmutablePropTypes.map, + groupsAccessor: PropTypes.func.isRequired, + isSuperAdmin: PropTypes.bool, + examBeginImmediately: PropTypes.bool, + examEndRelative: PropTypes.bool, + examPendingChange: PropTypes.bool, + setExamPeriod: PropTypes.func.isRequired, + removeExamPeriod: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, +}; + +const examFormSelector = formValueSelector('exam'); + +export default withLinks( + connect( + (state, { params: { groupId } }) => ({ + group: groupSelector(state, groupId), + groupsAccessor: groupDataAccessorSelector(state), + userId: loggedInUserIdSelector(state), + isSuperAdmin: isLoggedAsSuperAdmin(state), + examBeginImmediately: examFormSelector(state, 'beginImmediately'), + examEndRelative: examFormSelector(state, 'endRelative'), + examPendingChange: groupTypePendingChange(state, groupId), + }), + (dispatch, { params: { groupId } }) => ({ + loadAsync: () => dispatch(fetchGroupIfNeeded(groupId)), + reload: () => dispatch(fetchGroup(groupId)), + addNotification: (...args) => dispatch(addNotification(...args)), + setExamPeriod: (begin, end, strict) => dispatch(setExamPeriod(groupId, begin, end, strict)), + removeExamPeriod: () => dispatch(removeExamPeriod(groupId)), + }) + )(GroupExams) +); diff --git a/src/pages/GroupExams/index.js b/src/pages/GroupExams/index.js new file mode 100644 index 000000000..827a10442 --- /dev/null +++ b/src/pages/GroupExams/index.js @@ -0,0 +1,2 @@ +import GroupExams from './GroupExams'; +export default GroupExams; diff --git a/src/pages/GroupInfo/GroupInfo.js b/src/pages/GroupInfo/GroupInfo.js index e5601d4bc..e167760ff 100644 --- a/src/pages/GroupInfo/GroupInfo.js +++ b/src/pages/GroupInfo/GroupInfo.js @@ -43,7 +43,7 @@ import GroupsTreeContainer from '../../containers/GroupsTreeContainer'; import EditGroupForm, { EDIT_GROUP_FORM_EMPTY_INITIAL_VALUES } from '../../components/forms/EditGroupForm'; import AddSupervisor from '../../components/Groups/AddSupervisor'; import { BanIcon, GroupIcon } from '../../components/icons'; -import { hasPermissions, hasOneOfPermissions, safeGet } from '../../helpers/common'; +import { hasPermissions, safeGet } from '../../helpers/common'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning'; import withLinks from '../../helpers/withLinks'; @@ -122,11 +122,7 @@ class GroupInfo extends Component { failed={}> {data => (
- + {!isAdmin && !isSupervisor && diff --git a/src/pages/GroupStudents/GroupStudents.js b/src/pages/GroupStudents/GroupStudents.js index 843bd0919..c38f68d9f 100644 --- a/src/pages/GroupStudents/GroupStudents.js +++ b/src/pages/GroupStudents/GroupStudents.js @@ -53,7 +53,7 @@ import { isReady } from '../../redux/helpers/resourceManager/index'; import ResultsTable from '../../components/Groups/ResultsTable/ResultsTable'; import { isSuperadminRole, isStudentRole } from '../../components/helpers/usersRoles'; -import { EMPTY_LIST, hasPermissions, hasOneOfPermissions, safeGet } from '../../helpers/common'; +import { EMPTY_LIST, hasPermissions, safeGet } from '../../helpers/common'; import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning'; class GroupStudents extends Component { @@ -152,12 +152,7 @@ class GroupStudents extends Component { return (
- + {canLeaveGroup && (
diff --git a/src/pages/GroupUserSolutions/GroupUserSolutions.js b/src/pages/GroupUserSolutions/GroupUserSolutions.js index 45388f228..a23b79d05 100644 --- a/src/pages/GroupUserSolutions/GroupUserSolutions.js +++ b/src/pages/GroupUserSolutions/GroupUserSolutions.js @@ -59,7 +59,7 @@ import { compareAssignmentsReverted } from '../../components/helpers/assignments import { storageGetItem, storageSetItem } from '../../helpers/localStorage'; import { getLocalizedName } from '../../helpers/localizedData'; import withLinks from '../../helpers/withLinks'; -import { safeGet, identity, hasPermissions, hasOneOfPermissions, unique } from '../../helpers/common'; +import { safeGet, identity, hasPermissions, unique } from '../../helpers/common'; /** * Sorts all assignments and create a numerical index, so the solutions can be sorted faster @@ -391,12 +391,7 @@ class GroupUserSolutions extends Component { }> {group => (
- + meta: { groupId }, }); -/* -export const removeExam = groupId => +export const setExamPeriod = (groupId, begin, end = null, strict = undefined) => { + const body = { begin, end }; + if (strict !== undefined && strict !== null) { + body.strict = strict; + } + return createApiAction({ + type: additionalActionTypes.SET_EXAM_PERIOD, + method: 'POST', + endpoint: `/groups/${groupId}/examPeriod`, + body, + meta: { groupId }, + }); +}; + +export const removeExamPeriod = groupId => createApiAction({ - type: additionalActionTypes.REMOVE_EXAM, + type: additionalActionTypes.REMOVE_EXAM_PERIOD, method: 'DELETE', - endpoint: `/groups/${groupId}/exam`, + endpoint: `/groups/${groupId}/examPeriod`, meta: { groupId }, }); -*/ /** * Reducer @@ -296,16 +309,30 @@ const reducer = handleActions( [additionalActionTypes.SET_EXAM_FLAG_REJECTED]: (state, { meta: { groupId } }) => state.deleteIn(['resources', groupId, 'pending-group-type']), - /* - [additionalActionTypes.REMOVE_EXAM_PENDING]: (state, { meta: { groupId } }) => + [additionalActionTypes.SET_EXAM_PERIOD_PENDING]: (state, { meta: { groupId } }) => + state.setIn(['resources', groupId, 'pending-exam-period'], true), + + [additionalActionTypes.SET_EXAM_PERIOD_FULFILLED]: (state, { payload, meta: { groupId } }) => + state + .deleteIn(['resources', groupId, 'pending-exam-period']) + .setIn(['resources', groupId, 'data'], fromJS(payload)), + + [additionalActionTypes.SET_EXAM_PERIOD_REJECTED]: (state, { meta: { groupId } }) => + state.deleteIn(['resources', groupId, 'pending-exam-period']), + + [additionalActionTypes.REMOVE_EXAM_PERIOD_PENDING]: (state, { meta: { groupId } }) => state.setIn(['resources', groupId, 'pending-exam-period'], true), - [additionalActionTypes.REMOVE_EXAM_FULFILLED]: (state, { payload, meta: { groupId } }) => - state.deleteIn(['resources', groupId, 'pending-exam-period']).setIn(['resources', groupId, 'data'], fromJS(payload)), + [additionalActionTypes.REMOVE_EXAM_PERIOD_FULFILLED]: (state, { meta: { groupId } }) => + state + .deleteIn(['resources', groupId, 'pending-exam-period']) + .setIn(['resources', groupId, 'data', 'privateData', 'examBegin'], null) + .setIn(['resources', groupId, 'data', 'privateData', 'examEnd'], null) + .setIn(['resources', groupId, 'data', 'privateData', 'examLockStrict'], null), - [additionalActionTypes.REMOVE_EXAM_REJECTED]: (state, { meta: { groupId } }) => + [additionalActionTypes.REMOVE_EXAM_PERIOD_REJECTED]: (state, { meta: { groupId } }) => state.deleteIn(['resources', groupId, 'pending-exam-period']), -*/ + [additionalActionTypes.RELOCATE_FULFILLED]: (state, { payload }) => payload.reduce( (state, data) => state.setIn(['resources', data.id], createRecord({ state: resourceStatus.FULFILLED, data })),