{hasPermissions(group, 'setExamPeriod') && (
@@ -320,12 +392,21 @@ class GroupExamStatus extends Component {
GroupExamStatus.propTypes = {
group: PropTypes.shape({
+ id: PropTypes.string.isRequired,
privateData: PropTypes.shape({
examBegin: PropTypes.number,
examEnd: PropTypes.number,
examLockStrict: PropTypes.bool,
}).isRequired,
}).isRequired,
+ currentUser: PropTypes.shape({
+ privateData: PropTypes.shape({
+ ipLock: PropTypes.string,
+ groupLock: PropTypes.string,
+ isGroupLockStrict: PropTypes.bool,
+ role: PropTypes.string,
+ }).isRequired,
+ }).isRequired,
examBeginImmediately: PropTypes.bool,
examEndRelative: PropTypes.bool,
pending: PropTypes.bool,
diff --git a/src/components/buttons/ExamLockButton/ExamLockButton.js b/src/components/buttons/ExamLockButton/ExamLockButton.js
new file mode 100644
index 000000000..4c2d3fa20
--- /dev/null
+++ b/src/components/buttons/ExamLockButton/ExamLockButton.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { UserLockIcon, LoadingIcon } from '../../icons';
+import Button from '../../widgets/TheButton';
+
+const ExamLockButton = ({ pending, disabled = false, lockUserForExam, ...props }) => (
+
+);
+
+ExamLockButton.propTypes = {
+ pending: PropTypes.bool.isRequired,
+ disabled: PropTypes.bool,
+ lockUserForExam: PropTypes.func.isRequired,
+};
+
+export default ExamLockButton;
diff --git a/src/components/buttons/ExamLockButton/index.js b/src/components/buttons/ExamLockButton/index.js
new file mode 100644
index 000000000..c6dd61a2f
--- /dev/null
+++ b/src/components/buttons/ExamLockButton/index.js
@@ -0,0 +1,2 @@
+import ExamLockButton from './ExamLockButton';
+export default ExamLockButton;
diff --git a/src/components/icons/index.js b/src/components/icons/index.js
index 0c960ff34..c4f19994d 100644
--- a/src/components/icons/index.js
+++ b/src/components/icons/index.js
@@ -154,6 +154,7 @@ export const UndoIcon = props => ;
export const UnlockIcon = props => ;
export const UploadIcon = props => ;
export const UserIcon = props => ;
+export const UserLockIcon = props => ;
export const UserProfileIcon = props => ;
export const VisibleIcon = ({ visible = true, ...props }) =>
visible ? (
diff --git a/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js b/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js
new file mode 100644
index 000000000..f22f7b049
--- /dev/null
+++ b/src/containers/ExamLockButtonContainer/ExamLockButtonContainer.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { injectIntl } from 'react-intl';
+import { defaultMemoize } from 'reselect';
+
+import ExamLockButton from '../../components/buttons/ExamLockButton';
+import { lockStudentForExam } from '../../redux/modules/groups';
+import { groupPendingUserLock } from '../../redux/selectors/groups';
+import { loggedInUserSelector } from '../../redux/selectors/users';
+import ResourceRenderer from '../../components/helpers/ResourceRenderer';
+import { getErrorMessage } from '../../locales/apiErrorMessages';
+import { addNotification } from '../../redux/modules/notifications';
+
+import { isStudentRole } from '../../components/helpers/usersRoles';
+
+const lockStudentHandlingErrors = defaultMemoize(
+ (userId, lockStudentForExam, addNotification, formatMessage) => () =>
+ lockStudentForExam(userId).catch(err => {
+ addNotification(getErrorMessage(formatMessage)(err), false);
+ })
+);
+
+const ExamLockButtonContainer = ({
+ groupId,
+ currentUser,
+ pending = null,
+ lockStudentForExam,
+ addNotification,
+ intl: { formatMessage },
+ ...props
+}) => (
+
+ {({ id, privateData: { groupLock, role } }) => (
+
+ )}
+
+);
+
+ExamLockButtonContainer.propTypes = {
+ groupId: PropTypes.string.isRequired,
+ currentUser: ImmutablePropTypes.map,
+ pending: PropTypes.string,
+ lockStudentForExam: PropTypes.func.isRequired,
+ addNotification: PropTypes.func.isRequired,
+ intl: PropTypes.object,
+};
+
+const mapStateToProps = (state, { groupId }) => ({
+ pending: groupPendingUserLock(state, groupId),
+ currentUser: loggedInUserSelector(state),
+});
+
+const mapDispatchToProps = (dispatch, { groupId }) => ({
+ lockStudentForExam: userId => dispatch(lockStudentForExam(groupId, userId)),
+ addNotification: (...args) => dispatch(addNotification(...args)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ExamLockButtonContainer));
diff --git a/src/containers/ExamLockButtonContainer/index.js b/src/containers/ExamLockButtonContainer/index.js
new file mode 100644
index 000000000..c2b5869cf
--- /dev/null
+++ b/src/containers/ExamLockButtonContainer/index.js
@@ -0,0 +1,2 @@
+import ExamLockButtonContainer from './ExamLockButtonContainer';
+export default ExamLockButtonContainer;
diff --git a/src/helpers/common.js b/src/helpers/common.js
index c22b07c47..004078966 100644
--- a/src/helpers/common.js
+++ b/src/helpers/common.js
@@ -215,13 +215,44 @@ export const getFirstItemInOrder = (arr, comarator = _defaultComparator) => {
return res;
};
+/**
+ * Compare two entities (scalars, arrays, or objects). In case of arrays and objects,
+ * the items/properties are compared with strict '==='.
+ * @param {*} a
+ * @param {*} b
+ * @returns {boolean} true if the values are equal
+ */
+export const shallowCompare = (a, b) => {
+ if (typeof a !== typeof b) {
+ return false;
+ }
+
+ if (typeof a !== 'object' || a === null || b === null) {
+ return a === b; // compare scalars
+ }
+
+ if (Array.isArray(a) !== Array.isArray(b)) {
+ return false;
+ }
+
+ if (Array.isArray(a)) {
+ // compare arrays
+ return a.length === b.length ? a.every((val, idx) => val === b[idx]) : false;
+ } else {
+ // compare objects
+ const aKeys = Object.keys(a);
+ const bKeys = new Set(Object.keys(b));
+ return aKeys.length === bKeys.size ? aKeys.every(key => bKeys.has(key) && a[key] === b[key]) : false;
+ }
+};
+
/**
* Compare two entities (scalars, arrays, or objects). In case of arrays and objects,
* the items/properties are compared recursively.
* @param {*} a
* @param {*} b
* @param {boolean} emptyObjectArrayEquals if true, {} and [] are treated as equal
- * @returns {boolean} true if the values are matching
+ * @returns {boolean} true if the values are equal
*/
export const deepCompare = (a, b, emptyObjectArrayEquals = false) => {
if (typeof a !== typeof b) {
diff --git a/src/locales/cs.json b/src/locales/cs.json
index 0735ca5e3..fe82555b8 100644
--- a/src/locales/cs.json
+++ b/src/locales/cs.json
@@ -1064,10 +1064,13 @@
"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.endAtLong": "Zkouška 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.ipLockInfo": "Vaše akce budou příjmány pouze z IP adresy [{ipLock}] dokud budete v uzamčeném režimu. Kontaktujte vašeho zkoušejícího pokud potřebujete změnit svoje umístění.",
+ "app.groupExams.ipLocked": "IP zamčena",
"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í).",
@@ -1075,9 +1078,19 @@
"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.lockStudentButton": "Zamkount se",
+ "app.groupExams.lockedStudentInfo": "Nyní můžete vidět zkouškové úlohy a odevzdávat u nich řešení.",
+ "app.groupExams.lockedStudentInfoRegular": "K ostatním skupinám můžete přistupovat pouze v režimu pro čtení dokud jste v uzamčeném režimu.",
+ "app.groupExams.lockedStudentInfoStrict": "K ostatním skupinám nemáte přístup dokud jste v uzamčeném režimu.",
"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.pending.studentLockedTitle": "Jste uzamčeném režimu pro probíhající zkoušku",
+ "app.groupExams.pending.teacherInfo": "V tuto chvíli jsou zkouškové úlohy viditelné pouze studentům, kteří se uzamkli ve skupině.",
+ "app.groupExams.studentInfo": "Musíte se uzamknout ve skupině, abyste mohl(a) vidět zkouškové úlohy. Po čas uzamčení je komunikace s vámi omezena na vaši aktuální IP adresu.",
+ "app.groupExams.studentInfoRegular": "Navíc budete moct přistupovat k ostatním skupinám pouze v režimu pro čtení dokud budete v uzamčeném režimu.",
+ "app.groupExams.studentInfoStrict": "Navíc nebudete moct přistupovat k ostatním skupinám dokud budete v uzamčeném režimu.",
+ "app.groupExams.timeAccuracyWarning": "Lokální hodiny na vašem systému musí být dostatečně seřízené, jinak nemusí tato komponenta fungovat zcela správně.",
"app.groupExams.title": "Zkouškové termíny skupiny",
"app.groupInfo.title": "Podrobnosti a metadata skupiny",
"app.groupInvitationForm.expireAt": "Konec platnosti:",
diff --git a/src/locales/en.json b/src/locales/en.json
index aeb7507ca..93bbee7e4 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1064,10 +1064,13 @@
"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.endAtLong": "Exam ends at",
"app.groupExams.examModal.create": "Plan an examination in this group",
"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.ipLockInfo": "Your actions are restricted to IP address [{ipLock}] until the exam lock expires. Contact your exam supervisor if you require relocation.",
+ "app.groupExams.ipLocked": "IP locked",
"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).",
@@ -1075,9 +1078,19 @@
"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.lockStudentButton": "Lock In",
+ "app.groupExams.lockedStudentInfo": "You may now see and submit solutions to exam assignments.",
+ "app.groupExams.lockedStudentInfoRegular": "You may access other groups in read-only mode until the exam lock expires.",
+ "app.groupExams.lockedStudentInfoStrict": "You may not access any other groups until the exam lock expires.",
"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.pending.studentLockedTitle": "You are locked in for an exam",
+ "app.groupExams.pending.teacherInfo": "The exam assignments are currently visible only to students who have lock themselves in the group.",
+ "app.groupExams.studentInfo": "You need to lock yourself in to see the exam assignments. When locked, your actions will be restricted to your current IP address.",
+ "app.groupExams.studentInfoRegular": "Furthermore, you will be able to access other groups in a read-only mode until the exam lock expires.",
+ "app.groupExams.studentInfoStrict": "Furthermore, you will not be able to access other groups until the exam lock expires.",
+ "app.groupExams.timeAccuracyWarning": "Your local system clock should be sufficiently synchronized or this component may not work properly.",
"app.groupExams.title": "Group Exam Terms",
"app.groupInfo.title": "Group Details and Metadata",
"app.groupInvitationForm.expireAt": "Expire at:",
diff --git a/src/pages/GroupAssignments/GroupAssignments.js b/src/pages/GroupAssignments/GroupAssignments.js
index 394f77b6e..6f128bc33 100644
--- a/src/pages/GroupAssignments/GroupAssignments.js
+++ b/src/pages/GroupAssignments/GroupAssignments.js
@@ -15,6 +15,7 @@ import { AssignmentsIcon, AddIcon, BanIcon } from '../../components/icons';
import AssignmentsTable from '../../components/Assignments/Assignment/AssignmentsTable';
import ShadowAssignmentsTable from '../../components/Assignments/ShadowAssignment/ShadowAssignmentsTable';
import GroupArchivedWarning from '../../components/Groups/GroupArchivedWarning/GroupArchivedWarning';
+import GroupExamPending from '../../components/Groups/GroupExamPending';
import ResourceRenderer from '../../components/helpers/ResourceRenderer';
import LeaveJoinGroupButtonContainer from '../../containers/LeaveJoinGroupButtonContainer';
import ExercisesListContainer from '../../containers/ExercisesListContainer';
@@ -27,7 +28,7 @@ import { create as createExercise } from '../../redux/modules/exercises';
import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments';
import { loggedInUserIdSelector } from '../../redux/selectors/auth';
-import { getLoggedInUserEffectiveRole } from '../../redux/selectors/users';
+import { getLoggedInUserEffectiveRole, loggedInUserSelector } from '../../redux/selectors/users';
import {
groupSelector,
groupDataAccessorSelector,
@@ -118,6 +119,7 @@ class GroupAssignments extends Component {
render() {
const {
group,
+ currentUser,
groupsAccessor,
students,
effectiveRole,
@@ -135,12 +137,12 @@ class GroupAssignments extends Component {
return (
}
title={}
loading={}
failed={}>
- {data => {
+ {(data, currentUser) => {
const canLeaveGroup =
!isGroupAdmin &&
!isGroupSupervisor &&
@@ -180,6 +182,8 @@ class GroupAssignments extends Component {
)}
+
+
{data.organizational && (