@@ -181,6 +186,7 @@ const CourseForm = (props) => {
/>
);
}
+
if (props.course.initial_campaign_title) {
campaign = (
{
/>
);
}
+
let backCondition;
+
if (Features.wikiEd) {
backCondition = props.previousWikiEd;
} else {
@@ -198,15 +206,15 @@ const CourseForm = (props) => {
let backOrCancelButton;
-// Displays "Back" button if the user has clonable courses or is on the P&E dashboard; otherwise shows "Cancel" link.
-if (props.hasClonableCourses || props.defaultCourse !== 'ClassroomProgramCourse') {
- backOrCancelButton = (
-
- );
- } else {
- backOrCancelButton = (
- {I18n.t('application.cancel')}
- );
+ // Displays "Back" button if the user has clonable courses or is on the P&E dashboard; otherwise shows "Cancel" link.
+ if (props.hasClonableCourses || props.defaultCourse !== 'ClassroomProgramCourse') {
+ backOrCancelButton = (
+
+ );
+ } else {
+ backOrCancelButton = (
+ {I18n.t('application.cancel')}
+ );
}
return (
diff --git a/app/assets/javascripts/components/high_order/conditional.jsx b/app/assets/javascripts/components/high_order/conditional.jsx
index dd587cbda6..4a17967d9e 100644
--- a/app/assets/javascripts/components/high_order/conditional.jsx
+++ b/app/assets/javascripts/components/high_order/conditional.jsx
@@ -1,25 +1,24 @@
import React from 'react';
-import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
// Enables DRY and simple conditional components
// Renders items when 'show' prop is undefined
const Conditional = function (Component) {
- return createReactClass({
- displayName: `Conditional${Component.displayName}`,
+ const ConditionalComponent = (props) => {
+ if (props.show === undefined || props.show) {
+ return ();
+ }
+ return false;
+ };
- propTypes: {
- show: PropTypes.bool
- },
+ ConditionalComponent.displayName = `Conditional${Component.displayName}`;
- render() {
- if (this.props.show === undefined || this.props.show) {
- return ();
- }
- return false;
- }
- });
+ ConditionalComponent.propTypes = {
+ show: PropTypes.bool
+ };
+
+ return ConditionalComponent;
};
export default Conditional;
diff --git a/app/assets/javascripts/components/high_order/editable_redux.jsx b/app/assets/javascripts/components/high_order/editable_redux.jsx
index 8072c12e40..b7038673aa 100644
--- a/app/assets/javascripts/components/high_order/editable_redux.jsx
+++ b/app/assets/javascripts/components/high_order/editable_redux.jsx
@@ -1,8 +1,7 @@
// Used by any component that requires "Edit", "Save", and "Cancel" buttons
-import React from 'react';
+import React, { useState, useCallback } from 'react';
import { connect } from 'react-redux';
-import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { resetValidations } from '../../actions/validation_actions';
import { isValid, editPermissions } from '../../selectors';
@@ -17,79 +16,77 @@ const mapDispatchToProps = {
};
const EditableRedux = (Component, Label) => {
- const editableComponent = createReactClass({
- displayName: 'EditableRedux',
- propTypes: {
- course_id: PropTypes.any,
- current_user: PropTypes.object,
- editable: PropTypes.bool,
- resetState: PropTypes.func,
- persistCourse: PropTypes.func.isRequired,
- nameHasChanged: PropTypes.func.isRequired,
- isValid: PropTypes.bool.isRequired,
- resetValidations: PropTypes.func.isRequired
- },
-
- getInitialState() {
- return { editable: this.state ? this.state.editable : false };
- },
-
- cancelChanges() {
- if (typeof (this.props.resetState) === 'function') {
- this.props.resetState();
+ const EditableWrapper = (props) => {
+ const [editable, setEditable] = useState(false);
+
+ const cancelChanges = useCallback(() => {
+ if (typeof props.resetState === 'function') {
+ props.resetState();
}
- this.props.resetValidations();
- return this.toggleEditable();
- },
+ props.resetValidations();
+ toggleEditable();
+ }, [props.resetState, props.resetValidations]);
- saveChanges() {
+ const saveChanges = useCallback(() => {
// If there are validation problems, show error message
- if (!this.props.isValid) {
+ if (!props.isValid) {
return alert(I18n.t('error.form_errors'));
}
// If the course slug has not changed, persist the data and exit edit mode
- if (!this.props.nameHasChanged()) {
- this.props.persistCourse(this.props.course_id);
- return this.toggleEditable();
+ if (!props.nameHasChanged()) {
+ props.persistCourse(props.course_id);
+ return toggleEditable();
}
// If the course has been renamed, we first warn the user that this is happening.
if (confirm(I18n.t('editable.rename_confirmation'))) {
- return this.props.persistCourse(this.props.course_id, true);
+ return props.persistCourse(props.course_id, true);
}
- return this.cancelChanges();
- },
+ return cancelChanges();
+ }, [props.isValid, props.nameHasChanged, props.persistCourse, props.course_id, cancelChanges]);
- toggleEditable() {
- return this.setState({ editable: !this.state.editable });
- },
+ const toggleEditable = useCallback(() => {
+ setEditable(prev => !prev);
+ }, []);
- controls() {
- if (!this.props.editPermissions) { return null; }
+ const controls = useCallback(() => {
+ if (!props.editPermissions) { return null; }
- if (this.state.editable) {
+ if (editable) {
return (
-
-
+
+
);
- } else if (this.props.editable === undefined || this.props.editable) {
+ } else if (props.editable === undefined || props.editable) {
return (
-
+
);
}
- },
+ }, [props.editPermissions, editable, props.editable, cancelChanges, saveChanges, toggleEditable]);
- render() {
- return ;
- }
- });
- return connect(mapStateToProps, mapDispatchToProps)(editableComponent);
-};
+ return ;
+ };
+
+ EditableWrapper.displayName = 'EditableRedux';
+ EditableWrapper.propTypes = {
+ course_id: PropTypes.any,
+ current_user: PropTypes.object,
+ editable: PropTypes.bool,
+ resetState: PropTypes.func,
+ persistCourse: PropTypes.func.isRequired,
+ nameHasChanged: PropTypes.func.isRequired,
+ isValid: PropTypes.bool.isRequired,
+ resetValidations: PropTypes.func.isRequired,
+ editPermissions: PropTypes.bool
+ };
+
+ return connect(mapStateToProps, mapDispatchToProps)(EditableWrapper);
+};
export default EditableRedux;
diff --git a/app/assets/javascripts/components/overview/tag_list.jsx b/app/assets/javascripts/components/overview/tag_list.jsx
index a8ed3492c3..9e9b8bd76b 100644
--- a/app/assets/javascripts/components/overview/tag_list.jsx
+++ b/app/assets/javascripts/components/overview/tag_list.jsx
@@ -11,7 +11,7 @@ const TagList = ({ tags, course }) => {
const comma = (index !== lastIndex) ? ', ' : '';
return {tag.tag}{comma};
})
- : I18n.t('courses.none'));
+ : {I18n.t('courses.none')});
return (
diff --git a/app/assets/javascripts/components/settings/views/admin_user.jsx b/app/assets/javascripts/components/settings/views/admin_user.jsx
index e3d06105d9..de43663e35 100644
--- a/app/assets/javascripts/components/settings/views/admin_user.jsx
+++ b/app/assets/javascripts/components/settings/views/admin_user.jsx
@@ -1,21 +1,12 @@
-import createReactClass from 'create-react-class';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
-import React from 'react';
-const AdminUser = createReactClass({
- propTypes: {
- downgradeAdmin: PropTypes.func,
- user: PropTypes.shape({
- id: PropTypes.number,
- username: PropTypes.string.isRequired,
- real_name: PropTypes.string,
- permissions: PropTypes.number.isRequired,
- }),
- },
+const AdminUser = ({ user, downgradeAdmin, revokingAdmin }) => {
+ const [confirming, setConfirming] = useState(false);
- getInitialState() {
- return { confirming: false };
- },
+ const isRevoking = () => {
+ return revokingAdmin.status && revokingAdmin.username === user.username;
+ };
/*
returns the current state of the revoking button
@@ -23,83 +14,82 @@ const AdminUser = createReactClass({
2) "confirming"
3) "submitting"
*/
- getButtonState() {
- if (this.isRevoking()) {
+ const getButtonState = () => {
+ if (isRevoking()) {
return 'revoking';
- } else if (this.state.confirming) {
+ } else if (confirming) {
return 'confirming';
}
return 'not confirming';
- },
+ };
- handleClick() {
- if (this.state.confirming) {
+ const handleClick = () => {
+ if (confirming) {
// user has clicked button while confirming. Process!
- if (!this.isRevoking()) {
+ if (!isRevoking()) {
// only process if not currently revoking
- this.props.downgradeAdmin(this.props.user.username);
+ downgradeAdmin(user.username);
}
- this.setState({ confirming: false });
+ setConfirming(false);
} else {
- this.setState({ confirming: true });
+ setConfirming(true);
}
- },
-
- isRevoking() {
- const { user, revokingAdmin } = this.props;
- return revokingAdmin.status && revokingAdmin.username === user.username;
- },
+ };
- render() {
- const { user } = this.props;
- const adminLevel = user.permissions === 3
- ? 'Super Admin'
- : 'Admin';
-
- let buttonText;
- let buttonClass = 'button';
- switch (this.getButtonState()) {
- case 'confirming':
- buttonClass += ' danger';
- buttonText = I18n.t('settings.admin_users.remove.revoke_button_confirm', { username: user.username });
- break;
- case 'revoking':
- buttonText = I18n.t('settings.admin_users.remove.revoking_button_working');
- buttonClass += ' border';
- break;
- default:
- // not confirming
- buttonClass += ' danger';
- buttonText = I18n.t('settings.admin_users.remove.revoke_button');
- break;
- }
+ const adminLevel = user.permissions === 3 ? 'Super Admin' : 'Admin';
- return (
-
-
- {user.username}
- |
-
- {user.real_name}
- |
-
- {adminLevel}
- |
-
-
-
-
+ let buttonText;
+ let buttonClass = 'button';
+ switch (getButtonState()) {
+ case 'confirming':
+ buttonClass += ' danger';
+ buttonText = I18n.t('settings.admin_users.remove.revoke_button_confirm', { username: user.username });
+ break;
+ case 'revoking':
+ buttonText = I18n.t('settings.admin_users.remove.revoking_button_working');
+ buttonClass += ' border';
+ break;
+ default:
+ // not confirming
+ buttonClass += ' danger';
+ buttonText = I18n.t('settings.admin_users.remove.revoke_button');
+ break;
+ }
- |
+ return (
+
+
+ {user.username}
+ |
+
+ {user.real_name}
+ |
+
+ {adminLevel}
+ |
+
+
+
+
+ |
+
+ );
+};
-
- );
- },
-});
+AdminUser.propTypes = {
+ downgradeAdmin: PropTypes.func,
+ revokingAdmin: PropTypes.shape({
+ status: PropTypes.bool,
+ username: PropTypes.string
+ }),
+ user: PropTypes.shape({
+ id: PropTypes.number,
+ username: PropTypes.string.isRequired,
+ real_name: PropTypes.string,
+ permissions: PropTypes.number.isRequired,
+ }),
+};
export default AdminUser;
diff --git a/app/assets/javascripts/components/students/components/Articles/SelectedStudent/RevisionsList/StudentRevisionRow.jsx b/app/assets/javascripts/components/students/components/Articles/SelectedStudent/RevisionsList/StudentRevisionRow.jsx
index a042a8dda2..fcf0670ba2 100644
--- a/app/assets/javascripts/components/students/components/Articles/SelectedStudent/RevisionsList/StudentRevisionRow.jsx
+++ b/app/assets/javascripts/components/students/components/Articles/SelectedStudent/RevisionsList/StudentRevisionRow.jsx
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
// Components
import ContentAdded from '@components/students/shared/StudentList/Student/ContentAdded.jsx';
+import { setUploadFilters } from '~/app/assets/javascripts/actions/uploads_actions';
export const StudentRevisionRow = ({ course, isOpen, toggleDrawer, student, uploadsLink }) => {
return (
@@ -18,7 +19,7 @@ export const StudentRevisionRow = ({ course, isOpen, toggleDrawer, student, uplo
{ this.setUploadFilters([{ value: student.username, label: student.username }]); }}
+ onClick={() => { setUploadFilters([{ value: student.username, label: student.username }]); }}
>
{student.total_uploads || 0}
diff --git a/app/assets/javascripts/components/timeline/BlockList.jsx b/app/assets/javascripts/components/timeline/BlockList.jsx
index 0eb3e2bf02..db0cda418c 100644
--- a/app/assets/javascripts/components/timeline/BlockList.jsx
+++ b/app/assets/javascripts/components/timeline/BlockList.jsx
@@ -5,8 +5,8 @@ import { Flipper } from 'react-flip-toolkit';
const BlockList = ({ blocks, moveBlock, week_id, ...props }) => {
const springBlocks = blocks.map((block, i) => {
- block.order = i;
- return ;
+ const updatedBlock = { ...block, order: i };
+ return ;
});
return (
block.id).join('')} spring="stiff">
diff --git a/app/assets/javascripts/components/timeline/meetings.jsx b/app/assets/javascripts/components/timeline/meetings.jsx
index eb06704422..a945baee8b 100644
--- a/app/assets/javascripts/components/timeline/meetings.jsx
+++ b/app/assets/javascripts/components/timeline/meetings.jsx
@@ -1,6 +1,5 @@
-import React from 'react';
+import React, { useRef } from 'react';
import { connect } from 'react-redux';
-import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import CourseLink from '../common/course_link.jsx';
import Calendar from '../common/calendar.jsx';
@@ -10,151 +9,145 @@ import CourseDateUtils from '../../utils/course_date_utils.js';
import { updateCourse, persistCourse } from '../../actions/course_actions';
import { isValid } from '../../selectors';
-const Meetings = createReactClass({
- displayName: 'Meetings',
+const Meetings = (props) => {
+ const noDatesRef = useRef();
- propTypes: {
- weeks: PropTypes.array, // Comes indirectly from TimelineHandler
- course: PropTypes.object,
- updateCourse: PropTypes.func.isRequired,
- persistCourse: PropTypes.func.isRequired,
- isValid: PropTypes.bool.isRequired
- },
-
- disableSave(bool) {
- return this.setState({ saveDisabled: bool });
- },
-
- updateCourse(valueKey, value) {
- const toPass = this.props.course;
+ const updateCourseHandler = (valueKey, value) => {
+ const toPass = props.course;
toPass[valueKey] = value;
- return this.props.updateCourse(toPass);
- },
+ return props.updateCourse(toPass);
+ };
- updateCourseDates(valueKey, value) {
- const updatedCourse = CourseDateUtils.updateCourseDates(this.props.course, valueKey, value);
- return this.props.updateCourse(updatedCourse);
- },
+ const updateCourseDates = (valueKey, value) => {
+ const updatedCourse = CourseDateUtils.updateCourseDates(props.course, valueKey, value);
+ return props.updateCourse(updatedCourse);
+ };
- saveCourse(e) {
- if (this.props.isValid) {
- return this.props.persistCourse(this.props.course.slug);
+ const saveCourse = (e) => {
+ if (props.isValid) {
+ return props.persistCourse(props.course.slug);
}
e.preventDefault();
return alert(I18n.t('error.form_errors'));
- },
+ };
- updateCheckbox(e) {
- this.updateCourse('no_day_exceptions', e.target.checked);
- return this.updateCourse('day_exceptions', '');
- },
+ const updateCheckbox = (e) => {
+ updateCourseHandler('no_day_exceptions', e.target.checked);
+ return updateCourseHandler('day_exceptions', '');
+ };
- saveDisabledClass(course) {
+ const saveDisabledClass = (course) => {
const blackoutDatesSelected = course.day_exceptions && course.day_exceptions.length > 0;
const anyDatesSelected = course.weekdays && course.weekdays.indexOf(1) >= 0;
- const enable = blackoutDatesSelected || (anyDatesSelected && this.props.course.no_day_exceptions);
+ const enable = blackoutDatesSelected || (anyDatesSelected && props.course.no_day_exceptions);
if (enable) { return ''; }
return 'disabled';
- },
+ };
- render() {
- const course = this.props.course;
- if (!course) { return ; }
- const dateProps = CourseDateUtils.dateProps(course);
- let courseLinkClass = 'dark button ';
- courseLinkClass += this.saveDisabledClass(course);
- const courseLinkTarget = `/courses/${course.slug}/timeline`;
+ const { course } = props;
+ if (!course) { return ; }
- return (
-
-
- {I18n.t('timeline.course_dates')}
-
- {I18n.t('timeline.course_dates_instructions')}
-
-
-
-
-
-
-
- {I18n.t('timeline.assignment_dates_instructions')}
-
-
-
-
+ const dateProps = CourseDateUtils.dateProps(course);
+ let courseLinkClass = 'dark button ';
+ courseLinkClass += saveDisabledClass(course);
+ const courseLinkTarget = `/courses/${course.slug}/timeline`;
+
+ return (
+
+
+ {I18n.t('timeline.course_dates')}
+
+ {I18n.t('timeline.course_dates_instructions')}
+
+
+
-
-
-
+
+
+ {I18n.t('timeline.assignment_dates_instructions')}
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+ {I18n.t('timeline.done')}
+
-
- );
- }
-}
-);
+
+
+ );
+};
+
+Meetings.propTypes = {
+ weeks: PropTypes.array, // Comes indirectly from TimelineHandler
+ course: PropTypes.object,
+ updateCourse: PropTypes.func.isRequired,
+ persistCourse: PropTypes.func.isRequired,
+ isValid: PropTypes.bool.isRequired
+};
const mapStateToProps = state => ({
isValid: isValid(state)
diff --git a/app/assets/javascripts/components/timeline/timeline_handler.jsx b/app/assets/javascripts/components/timeline/timeline_handler.jsx
index a497f20a82..bb0a6e6fc0 100644
--- a/app/assets/javascripts/components/timeline/timeline_handler.jsx
+++ b/app/assets/javascripts/components/timeline/timeline_handler.jsx
@@ -1,5 +1,5 @@
// Import necessary hooks (useState, useEffect) and useNavigate from react-router-dom
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import TransitionGroup from '../common/css_transition_group';
@@ -25,12 +25,21 @@ const TimelineHandler = (props) => {
const [reorderable, setReorderable] = useState(false);
const [editableTitles, setEditableTitles] = useState(false);
-// Replace componentDidMount with useEffect hook
+ const resetWeekTitles = useRef(false);
+
+ // Replace componentDidMount with useEffect hook
useEffect(() => {
document.title = `${props.course.title} - ${I18n.t('courses.timeline_link')}`;
props.fetchAllTrainingModules();
}, [props.course.title, props.fetchAllTrainingModules]);
+ useEffect(() => {
+ if (resetWeekTitles.current) {
+ saveTimeline();
+ resetWeekTitles.current = false;
+ }
+ }, [props.weeks]);
+
// Convert class methods to regular functions within the component
const _cancelBlockEditable = (blockId) => {
// TODO: Restore to persisted state for this block only
@@ -54,7 +63,7 @@ const TimelineHandler = (props) => {
const _resetTitles = () => {
if (confirm(I18n.t('timeline.reset_titles_confirmation'))) {
props.resetTitles();
- saveTimeline();
+ resetWeekTitles.current = true;
}
};
diff --git a/app/assets/javascripts/components/util/create_store.js b/app/assets/javascripts/components/util/create_store.js
index 98bbd57184..95a5fbb3a2 100644
--- a/app/assets/javascripts/components/util/create_store.js
+++ b/app/assets/javascripts/components/util/create_store.js
@@ -29,8 +29,8 @@ export const getStore = () => {
};
}
- // Determine if mutation checks should be enabled
- const enableMutationChecks = false;
+ // Determine if Redux Toolkit's safety checks should be enabled
+ const enableReduxSafetyChecks = false;
const store = configureStore({
reducer,
@@ -39,9 +39,9 @@ export const getStore = () => {
getDefaultMiddleware({
// Temporarily disable mutation checks feature to facilitate Redux Toolkit migration.
// TODO: Gradually resolve state mutations and re-enable these checks in the future.
- // Enable mutation checks when resolving or detecting these issues by setting enableMutationChecks to true.
- immutableCheck: enableMutationChecks,
- serializableCheck: enableMutationChecks,
+ // Enable mutation checks when resolving or detecting these issues by setting enableReduxSafetyChecks to true.
+ immutableCheck: enableReduxSafetyChecks,
+ serializableCheck: enableReduxSafetyChecks,
}),
});
diff --git a/app/assets/javascripts/components/wizard/form_panel.jsx b/app/assets/javascripts/components/wizard/form_panel.jsx
index 142e2d92bd..71d0be8d60 100644
--- a/app/assets/javascripts/components/wizard/form_panel.jsx
+++ b/app/assets/javascripts/components/wizard/form_panel.jsx
@@ -1,144 +1,138 @@
-import React from 'react';
-import createReactClass from 'create-react-class';
+import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import Panel from './panel.jsx';
import DatePicker from '../common/date_picker.jsx';
import Calendar from '../common/calendar.jsx';
import CourseDateUtils from '../../utils/course_date_utils.js';
-const FormPanel = createReactClass({
- displayName: 'FormPanel',
+const FormPanel = (props) => {
+ const noDates = useRef();
- propTypes: {
- course: PropTypes.object.isRequired,
- shouldShowSteps: PropTypes.bool,
- updateCourse: PropTypes.func.isRequired,
- isValid: PropTypes.bool.isRequired
- },
-
- setAnyDatesSelected(bool) {
- return this.setState({ anyDatesSelected: bool });
- },
-
- setBlackoutDatesSelected(bool) {
- return this.setState({ blackoutDatesSelected: bool });
- },
- setNoBlackoutDatesChecked() {
- const { checked } = this.noDates;
- const toPass = this.props.course;
+ const setNoBlackoutDatesChecked = () => {
+ const { checked } = noDates.current;
+ const toPass = props.course;
toPass.no_day_exceptions = checked;
- return this.props.updateCourse(toPass);
- },
+ return props.updateCourse(toPass);
+ };
- updateCourseDates(valueKey, value) {
- const updatedCourse = CourseDateUtils.updateCourseDates(this.props.course, valueKey, value);
- return this.props.updateCourse(updatedCourse);
- },
+ const updateCourseDates = (valueKey, value) => {
+ const updatedCourse = CourseDateUtils.updateCourseDates(props.course, valueKey, value);
+ return props.updateCourse(updatedCourse);
+ };
- saveCourse() {
- if (this.props.isValid) {
- this.props.persistCourse(this.props.course.slug);
+ const saveCourse = () => {
+ if (props.isValid) {
+ props.persistCourse(props.course.slug);
return true;
}
alert(I18n.t('error.form_errors'));
return false;
- },
- nextEnabled() {
- if (__guard__(this.props.course.weekdays, x => x.indexOf(1)) >= 0 && (__guard__(this.props.course.day_exceptions, x1 => x1.length) > 0 || this.props.course.no_day_exceptions)) {
+ };
+
+ const nextEnabled = () => {
+ if (__guard__(props.course.weekdays, x => x.indexOf(1)) >= 0
+ && (__guard__(props.course.day_exceptions, x1 => x1.length) > 0 || props.course.no_day_exceptions)) {
return true;
}
return false;
- },
+ };
- render() {
- const dateProps = CourseDateUtils.dateProps(this.props.course);
+ const dateProps = CourseDateUtils.dateProps(props.course);
- const step1 = this.props.shouldShowSteps
- ? 1. Confirm the course’s start and end dates.
- : Confirm the course’s start and end dates. ;
+ const step1 = props.shouldShowSteps
+ ? 1. {I18n.t('wizard.confirm_course_dates')}
+ : {I18n.t('wizard.confirm_course_dates')} ;
- const rawOptions = (
-
-
-
-
- {I18n.t('wizard.assignment_description')}
-
-
-
-
+ const rawOptions = (
+
+
+ {step1}
+
+
+
-
-
-
+
+
+ {I18n.t('wizard.assignment_description')}
+
+
+
-
- );
+
+
+
+
+
+
+ );
- return (
-
- );
- }
-});
+ return (
+
+ );
+};
-export default FormPanel;
+FormPanel.propTypes = {
+ course: PropTypes.object.isRequired,
+ shouldShowSteps: PropTypes.bool,
+ updateCourse: PropTypes.func.isRequired,
+ isValid: PropTypes.bool.isRequired
+};
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}
+
+export default FormPanel;
diff --git a/app/assets/javascripts/reducers/notifications.js b/app/assets/javascripts/reducers/notifications.js
index 271fd8a98a..98f8d3370c 100644
--- a/app/assets/javascripts/reducers/notifications.js
+++ b/app/assets/javascripts/reducers/notifications.js
@@ -24,12 +24,12 @@ const handleErrorNotification = function (data) {
try {
notification.message = JSON.parse(data.responseText).message;
} catch (error) {
- // do nothing nothing
+ // do nothing
}
}
- if (data.responseJSON && data.responseJSON.error) {
- if (!notification.message) { notification.message = data.responseJSON.error; }
+ if (!notification.message && data.responseJSON && data.responseJSON.error) {
+ notification.message = data.responseJSON.error;
}
if (!notification.message) {
@@ -45,6 +45,10 @@ const handleErrorNotification = function (data) {
console.log(data); // eslint-disable-line no-console
}
+ if (typeof notification.message !== 'string') {
+ notification.message = JSON.stringify(notification.message);
+ }
+
return notification;
};
diff --git a/app/assets/javascripts/reducers/timeline.js b/app/assets/javascripts/reducers/timeline.js
index c337f129f7..47b360baeb 100644
--- a/app/assets/javascripts/reducers/timeline.js
+++ b/app/assets/javascripts/reducers/timeline.js
@@ -14,6 +14,7 @@ import {
RESTORE_TIMELINE,
EXERCISE_COMPLETION_UPDATE
} from '../constants';
+import { produce } from 'immer';
const initialState = {
blocks: {},
@@ -207,18 +208,18 @@ export default function timeline(state = initialState, action) {
return { ...state, blocks };
}
case UPDATE_TITLE: {
- const weeks = { ...state.weeks };
- if (validateTitle(action.title)) {
- weeks[action.weekId].title = action.title;
- }
- return { ...state, weeks };
+ return produce(state, (draft) => {
+ if (validateTitle(action.title)) {
+ draft.weeks[action.weekId].title = action.title;
+ }
+ });
}
case RESET_TITLES: {
- const weeks = { ...state.weeks };
- Object.keys(weeks).forEach((weekId) => {
- weeks[weekId].title = '';
+ return produce(state, (draft) => {
+ Object.keys(draft.weeks).forEach((weekId) => {
+ draft.weeks[weekId].title = '';
+ });
});
- return { ...state, weeks };
}
case RESTORE_TIMELINE: {
return { ...state, blocks: { ...state.blocksPersisted }, weeks: deepCopyWeeks(state.weeksPersisted), editableBlockIds: [] };
diff --git a/app/assets/javascripts/reducers/uploads.js b/app/assets/javascripts/reducers/uploads.js
index 69e90ed293..f5c256969e 100644
--- a/app/assets/javascripts/reducers/uploads.js
+++ b/app/assets/javascripts/reducers/uploads.js
@@ -30,7 +30,7 @@ const SORT_DESCENDING = {
export default function uploads(state = initialState, action) {
switch (action.type) {
case RECEIVE_UPLOADS: {
- const dataUploads = action.data.course.uploads;
+ const dataUploads = action.data?.course?.uploads || [];
// Intial sorting by upload date
const sortedModel = sortByKey(dataUploads, 'uploaded_at', state.sortKey, SORT_DESCENDING.uploaded_at);
diff --git a/app/assets/javascripts/selectors/index.js b/app/assets/javascripts/selectors/index.js
index c6e9ab8d7a..262587ae6d 100644
--- a/app/assets/javascripts/selectors/index.js
+++ b/app/assets/javascripts/selectors/index.js
@@ -254,8 +254,7 @@ export const getWeeksArray = createSelector(
});
weekIds.forEach((weekId) => {
- const newWeek = weeks[weekId];
- newWeek.blocks = blocksByWeek[weekId] || [];
+ const newWeek = { ...weeks[weekId], blocks: blocksByWeek[weekId] || [] };
weeksArray.push(newWeek);
});
diff --git a/app/assets/javascripts/surveys/modules/Survey.js b/app/assets/javascripts/surveys/modules/Survey.js
index dc8dab9f88..2aad692baa 100644
--- a/app/assets/javascripts/surveys/modules/Survey.js
+++ b/app/assets/javascripts/surveys/modules/Survey.js
@@ -698,50 +698,45 @@ const Survey = {
}
},
- // FIXME: This is supposed to remove a conditional question from
- // the flow if the condition that it depends on has changed.
- // However, when this happens it leaves the survey in a state
- // with no visible questions and no way to proceed.
- // Disabling this feature means that, once inserted, a conditional
- // question will not be removed, but that's better than a broken survey.
- resetConditionalGroupChildren(/* conditionalGroup */) {
- // const { children, currentAnswers } = conditionalGroup;
-
- // if ((typeof currentAnswers !== 'undefined' && currentAnswers !== null) && currentAnswers.length) {
- // const excludeFromReset = [];
- // currentAnswers.forEach((a) => { excludeFromReset.push(a); });
- // children.forEach((question) => {
- // const $question = $(question);
- // let string;
- // if ($question.data('conditional-question')) {
- // string = $question.data('conditional-question');
- // } else {
- // string = $question.find('[data-conditional-question]').data('conditional-question');
- // }
- // const { value } = Utils.parseConditionalString(string);
- // if (excludeFromReset.indexOf(value) === -1) {
- // this.resetConditionalQuestion($question);
- // } else {
- // $question.removeClass('hidden');
- // }
- // });
- // } else {
- // children.forEach((question) => {
- // this.resetConditionalQuestion($(question));
- // if ($(question).hasClass('survey__question-row')) {
- // const $parentBlock = $(question).parents(BLOCK_CONTAINER_SELECTOR);
- // const blockIndex = $(question).data('block-index');
- // if (!($parentBlock.find('.survey__question-row:not([data-conditional-question])').length > 1)) {
- // this.resetConditionalQuestion($parentBlock);
- // if (this.detachedParentBlocks[blockIndex] === undefined) {
- // this.detachedParentBlocks[blockIndex] = $parentBlock;
- // this.removeSlide($parentBlock);
- // $parentBlock.detach();
- // }
- // }
- // }
- // });
- // }
+ // To remove a conditional question from the flow if the condition that it depends on has changed.
+ resetConditionalGroupChildren(conditionalGroup) {
+ const { children, currentAnswers } = conditionalGroup;
+
+ if ((typeof currentAnswers !== 'undefined' && currentAnswers !== null) && currentAnswers.length) {
+ const excludeFromReset = [];
+ currentAnswers.forEach((a) => { excludeFromReset.push(a); });
+ children.forEach((question) => {
+ const $question = $(question);
+ let string;
+ if ($question.data('conditional-question')) {
+ string = $question.data('conditional-question');
+ } else {
+ string = $question.find('[data-conditional-question]').data('conditional-question');
+ }
+ const { value } = Utils.parseConditionalString(string);
+ if (excludeFromReset.indexOf(value) === -1) {
+ this.resetConditionalQuestion($question);
+ } else {
+ $question.removeClass('hidden');
+ }
+ });
+ } else {
+ children.forEach((question) => {
+ this.resetConditionalQuestion($(question));
+ if ($(question).hasClass('survey__question-row')) {
+ const $parentBlock = $(question).parents(BLOCK_CONTAINER_SELECTOR);
+ const blockIndex = $(question).data('block-index');
+ if (!($parentBlock.find('.survey__question-row:not([data-conditional-question])').length > 1)) {
+ this.resetConditionalQuestion($parentBlock);
+ if (this.detachedParentBlocks[blockIndex] === undefined) {
+ this.detachedParentBlocks[blockIndex] = $parentBlock;
+ this.removeSlide($parentBlock);
+ $parentBlock.detach();
+ }
+ }
+ }
+ });
+ }
},
removeSlide($block) {
@@ -753,7 +748,29 @@ const Survey = {
if ($question.hasClass('survey__question-row')) {
$question.removeAttr('style').addClass('hidden not-seen disabled');
} else {
+ // Find which question group/slider this question belongs to by climbing up the DOM tree
+ const $grandParents = $question.parents('[data-question-group-blocks]');
+
+ // Extract the group's index number so we know which specific slider we need to modify
+ const questionGroupIndex = $grandParents.data('question-group-blocks');
+
+ // Get the actual slider jQuery object that we need to remove the question from
+ const $slider = this.groupSliders[questionGroupIndex];
+
+ // Get the slide index before detaching
+ const slideIndex = $question.data('slick-index');
+
+ // Remove the slide from Slick first
+ $slider?.slick('slickRemove', slideIndex);
+
+ // Then detach the element
$question.detach();
+
+ // Updates Progress Bar on Top Right of the UI.
+ this.indexBlocks();
+
+ // Update Slide Button to Determine whether to show Next or Submit button.
+ this.updateButtonText();
}
$question.find('input[type=text], textarea').val('');
$question.find('input:checked').removeAttr('checked');
diff --git a/app/assets/javascripts/training/components/training_slide_handler.jsx b/app/assets/javascripts/training/components/training_slide_handler.jsx
index e9e3a8e5fe..3f440fd823 100644
--- a/app/assets/javascripts/training/components/training_slide_handler.jsx
+++ b/app/assets/javascripts/training/components/training_slide_handler.jsx
@@ -164,7 +164,7 @@ const TrainingSlideHandler = () => {
params={routeParams}
onClick={next}
/>
- {isShown && }
+ {isShown &&
}
>
);
} else {
diff --git a/app/assets/javascripts/utils/article_viewer.js b/app/assets/javascripts/utils/article_viewer.js
index d8374e01f1..2b3c6a5081 100644
--- a/app/assets/javascripts/utils/article_viewer.js
+++ b/app/assets/javascripts/utils/article_viewer.js
@@ -7,7 +7,11 @@ export const printArticleViewer = () => {
pageHeader.classList.add('header-print-article-viewer');
pageHeader.appendChild(document.querySelector('.article-viewer-title').cloneNode(true));
- pageHeader.appendChild(document.querySelector('.user-legend-wrap').cloneNode(true));
+
+ const userLegendWrap = document.querySelector('.user-legend-wrap');
+ if (userLegendWrap) {
+ pageHeader.appendChild(userLegendWrap.cloneNode(true));
+ }
doc.write(pageHeader.outerHTML);
doc.write(document.querySelector('#article-scrollbox-id').innerHTML);
diff --git a/app/assets/javascripts/utils/course.js b/app/assets/javascripts/utils/course.js
index 0505e47a5c..10338910e8 100644
--- a/app/assets/javascripts/utils/course.js
+++ b/app/assets/javascripts/utils/course.js
@@ -7,7 +7,7 @@ document.onreadystatechange = () => {
if (e.target.tagName === 'BUTTON') return;
const loc = e.currentTarget.dataset.link;
- if (e.metaKey || (window.navigator.userAgentData.platform.toLowerCase().indexOf('win') !== -1 && e.ctrlKey)) {
+ if (e.metaKey || (window.navigator.userAgentData?.platform?.toLowerCase().includes('win') && e.ctrlKey)) {
window.open(loc, '_blank');
} else {
window.location = loc;
@@ -18,7 +18,7 @@ document.onreadystatechange = () => {
// Course sorting
// only sort if there are tables to sort
let courseList;
- if (document.querySelectorAll('#courses table').length) {
+ if (isTableValid('#courses')) {
courseList = new List('courses', {
page: 500,
valueNames: [
@@ -31,7 +31,7 @@ document.onreadystatechange = () => {
// Course Results sorting
// only sort if there are tables to sort
let courseResultList;
- if (document.querySelectorAll('#course_results table').length) {
+ if (isTableValid('#course_results')) {
courseResultList = new List('course_results', {
page: 500,
valueNames: [
@@ -44,7 +44,7 @@ document.onreadystatechange = () => {
// Campaign sorting
// only sort if there are tables to sort
let campaignList;
- if (document.querySelectorAll('#campaigns table').length) {
+ if (isTableValid('#campaigns')) {
campaignList = new List('campaigns', {
page: 500,
valueNames: [
@@ -56,7 +56,7 @@ document.onreadystatechange = () => {
// Article sorting
// only sort if there are tables to sort
let articlesList;
- if (document.querySelectorAll('#campaign-articles table').length) {
+ if (isTableValid('#campaign-articles')) {
articlesList = new List('campaign-articles', {
page: 10000,
valueNames: [
@@ -68,7 +68,7 @@ document.onreadystatechange = () => {
// Student sorting
// only sort if there are tables to sort
let studentsList;
- if (document.querySelectorAll('#users table').length) {
+ if (isTableValid('#users')) {
studentsList = new List('users', {
page: 10000,
valueNames: [
@@ -77,6 +77,20 @@ document.onreadystatechange = () => {
});
}
+ function isTableValid(selector) {
+ const tables = document.querySelectorAll(`${selector} table`);
+ if (tables.length === 0) return false;
+
+ let isValid = false;
+ tables.forEach((table) => {
+ const tbody = table.querySelector('tbody');
+ if (tbody && tbody.children.length > 0) {
+ isValid = true;
+ }
+ });
+ return isValid;
+ }
+
// for use on campaign/programs page
const removeCourseBtn = document.querySelectorAll('.remove-course');
for (let i = 0; i < removeCourseBtn.length; i += 1) {
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index b4f3b2d6e7..b7928298f0 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -46,8 +46,8 @@ def update_wiki(wiki)
def add_category(params)
name = ArticleUtils.format_article_title(params[:name])
- @category = Category.find_or_create_by(wiki: @wiki, depth: params[:depth],
- name:, source: params[:source])
+ @category = Category.get_or_create(wiki: @wiki, depth: params[:depth],
+ name:, source: params[:source])
@course.categories << @category
end
end
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
index 49cbcf7ad5..ded7925149 100644
--- a/app/controllers/courses_controller.rb
+++ b/app/controllers/courses_controller.rb
@@ -142,10 +142,10 @@ def alerts
def classroom_program_students_json
courses = Course.classroom_program_students
render json: courses.as_json(
- only: %i[title created_at updated_at start end school term slug],
+ only: %i[title school slug],
include: {
students: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
}
}
)
@@ -154,13 +154,13 @@ def classroom_program_students_json
def classroom_program_students_and_instructors_json
courses = Course.classroom_program_students_and_instructors
render json: courses.as_json(
- only: %i[title created_at updated_at start end school term slug],
+ only: %i[title school slug],
include: {
students: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
},
instructors: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
}
}
)
@@ -169,10 +169,10 @@ def classroom_program_students_and_instructors_json
def fellows_cohort_students_json
courses = Course.fellows_cohort_students
render json: courses.as_json(
- only: %i[title created_at updated_at start end school term slug],
+ only: %i[title school slug],
include: {
students: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
}
}
)
@@ -181,13 +181,13 @@ def fellows_cohort_students_json
def fellows_cohort_students_and_instructors_json
courses = Course.fellows_cohort_students_and_instructors
render json: courses.as_json(
- only: %i[title created_at updated_at start end school term slug],
+ only: %i[title school slug],
include: {
students: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
},
instructors: {
- only: %i[username created_at updated_at permissions]
+ only: %i[username]
}
}
)
@@ -408,13 +408,12 @@ def update_course_format
def update_last_reviewed
username = params.dig(:course, 'last_reviewed', 'username')
timestamp = params.dig(:course, 'last_reviewed', 'timestamp')
- if username && timestamp
- @course.flags['last_reviewed'] = {
- 'username' => username,
- 'timestamp' => timestamp
- }
- @course.save
- end
+ return unless username && timestamp
+ @course.flags['last_reviewed'] = {
+ 'username' => username,
+ 'timestamp' => timestamp
+ }
+ @course.save
end
def handle_post_course_creation_updates
diff --git a/app/controllers/onboarding_controller.rb b/app/controllers/onboarding_controller.rb
index 28f5680c8c..245701903b 100644
--- a/app/controllers/onboarding_controller.rb
+++ b/app/controllers/onboarding_controller.rb
@@ -22,6 +22,7 @@ def onboard
permissions: @permissions,
onboarded: true)
update_real_names_on_courses if Features.wiki_ed?
+ EnrollmentReminderEmailWorker.schedule_reminder(@user)
CheckWikiEmailWorker.check(user: @user)
head :no_content
end
diff --git a/app/controllers/personal_data_controller.rb b/app/controllers/personal_data_controller.rb
index cf35ad6107..c566f472b0 100644
--- a/app/controllers/personal_data_controller.rb
+++ b/app/controllers/personal_data_controller.rb
@@ -1,11 +1,23 @@
# frozen_string_literal: true
-# Allows users to download the personal data bout them stored on the Dashboard
+require_dependency "#{Rails.root}/lib/personal_data/personal_data_csv_builder.rb"
+
+# Allows users to download the personal data about them stored on the Dashboard
class PersonalDataController < ApplicationController
before_action :require_signed_in
- respond_to :json
+ respond_to :json, :csv
def show
@user = current_user
end
+
+ def personal_data_csv
+ @user = current_user
+ csv_data = PersonalData::PersonalDataCsvBuilder.new(@user).generate_csv
+
+ send_data csv_data,
+ type: 'text/csv',
+ disposition: 'attachment',
+ filename: "#{@user.username}_personal_data_#{Time.zone.today}.csv"
+ end
end
diff --git a/app/controllers/requested_accounts_controller.rb b/app/controllers/requested_accounts_controller.rb
index 561651570b..a38740ac68 100644
--- a/app/controllers/requested_accounts_controller.rb
+++ b/app/controllers/requested_accounts_controller.rb
@@ -130,10 +130,9 @@ def passcode_valid?
def handle_existing_request
existing_request = RequestedAccount.find_by(course: @course, username: params[:username])
- if existing_request
- existing_request.update(email: params[:email])
- render json: { message: existing_request.updated_email_message }
- yield
- end
+ return unless existing_request
+ existing_request.update(email: params[:email])
+ render json: { message: existing_request.updated_email_message }
+ yield
end
end
diff --git a/app/controllers/system_status_controller.rb b/app/controllers/system_status_controller.rb
new file mode 100644
index 0000000000..36f43909f7
--- /dev/null
+++ b/app/controllers/system_status_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class SystemStatusController < ApplicationController
+ def index
+ system_metrics = SystemMetrics.new
+
+ @sidekiq_stats = system_metrics.fetch_sidekiq_stats
+ @queue_metrics = system_metrics.fetch_queue_management_metrics
+ end
+end
diff --git a/app/helpers/article_helper.rb b/app/helpers/article_helper.rb
index ae1ade0ce6..277b990fbe 100644
--- a/app/helpers/article_helper.rb
+++ b/app/helpers/article_helper.rb
@@ -35,25 +35,16 @@ def rating_priority(rating)
def rating_display(rating)
rating = default_class(rating)
return nil if rating.nil?
- if %w[fa ga fl].include? rating
- return rating
- else
- return rating[0] # use the first letter of the rating as the abbreviated version
- end
+ return rating if %w[fa ga fl].include? rating
+ return rating[0] # use the first letter of the rating as the abbreviated version
end
def default_class(rating)
# Handles the different article classes and returns a known article class
- if %w[fa fl a ga b c start stub list].include? rating
- return rating
- elsif rating.eql? 'bplus'
- return 'b'
- elsif rating.eql? 'a/ga'
- return 'a'
- elsif %w[al bl cl sl].include? rating
- return 'list'
- else
- return nil
- end
+ return rating if %w[fa fl a ga b c start stub list].include? rating
+ return 'b' if rating.eql? 'bplus'
+ return 'a' if rating.eql? 'a/ga'
+ return 'list' if %w[al bl cl sl].include? rating
+ return nil
end
end
diff --git a/app/helpers/uploads_helper.rb b/app/helpers/uploads_helper.rb
index 281081c876..9e4fc8c8e4 100644
--- a/app/helpers/uploads_helper.rb
+++ b/app/helpers/uploads_helper.rb
@@ -3,7 +3,7 @@
#= Helpers for course views
module UploadsHelper
def pretty_filename(upload)
- pretty = upload.file_name
+ pretty = CGI.unescape(upload.file_name)
pretty['File:'] = ''
pretty
end
diff --git a/app/mailers/blocked_user_alert_mailer.rb b/app/mailers/blocked_user_alert_mailer.rb
index 9cfa4f6f06..efeaa9b908 100644
--- a/app/mailers/blocked_user_alert_mailer.rb
+++ b/app/mailers/blocked_user_alert_mailer.rb
@@ -10,6 +10,7 @@ def email(alert)
@alert = alert
set_recipients
return if @recipients.empty?
+ return unless @alert.course # A user who isn't in a course may trigger an alert.
params = { to: @recipients,
subject: @alert.main_subject }
params[:reply_to] = @alert.reply_to unless @alert.reply_to.nil?
diff --git a/app/mailers/enrollment_reminder_mailer.rb b/app/mailers/enrollment_reminder_mailer.rb
new file mode 100644
index 0000000000..5e5d1504cd
--- /dev/null
+++ b/app/mailers/enrollment_reminder_mailer.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class EnrollmentReminderMailer < ApplicationMailer
+ def self.send_reminder(user)
+ return unless Features.email? && Features.wiki_ed?
+ return if user.email.nil?
+ email(user).deliver_now
+ end
+
+ def email(user)
+ @user = user
+ mail(to: @user.email, subject: 'Next steps on Wiki Education Dashboard')
+ end
+end
diff --git a/app/models/campaign.rb b/app/models/campaign.rb
index bd5d147c0d..d7a6ef2a70 100644
--- a/app/models/campaign.rb
+++ b/app/models/campaign.rb
@@ -171,10 +171,9 @@ def valid_start_and_end_dates?
def set_slug
campaign_slug = slug.presence || title
self.slug = campaign_slug.downcase
- unless /^[\p{L}0-9_]+$/.match?(campaign_slug.downcase)
- # Strip everything but unicode letters and digits, and convert spaces to underscores.
- self.slug = campaign_slug.downcase.gsub(/[^\p{L}0-9 ]/, '').tr(' ', '_')
- end
+ return if /^[\p{L}0-9_]+$/.match?(campaign_slug.downcase)
+ # Strip everything but unicode letters and digits, and convert spaces to underscores.
+ self.slug = campaign_slug.downcase.gsub(/[^\p{L}0-9 ]/, '').tr(' ', '_')
end
def set_default_times
diff --git a/app/models/user_data/training_modules_users.rb b/app/models/user_data/training_modules_users.rb
index f692a41c18..33379878b3 100644
--- a/app/models/user_data/training_modules_users.rb
+++ b/app/models/user_data/training_modules_users.rb
@@ -33,7 +33,8 @@ def eligible_for_completion?(wiki)
# If module doesn't have a sandbox_location, there's nothing to check.
return true unless training_module.sandbox_location
- sandbox_content = WikiApi.new(wiki).get_page_content exercise_sandbox_location
+ # Via the API, we send the title without the URL encoding of special characters.
+ sandbox_content = WikiApi.new(wiki).get_page_content CGI.unescape exercise_sandbox_location
sandbox_content.present?
end
diff --git a/app/models/wiki_content/category.rb b/app/models/wiki_content/category.rb
index 638f28cd60..4ab2bfb199 100644
--- a/app/models/wiki_content/category.rb
+++ b/app/models/wiki_content/category.rb
@@ -38,6 +38,22 @@ class Category < ApplicationRecord
less_than_or_equal_to: 3
}
+ def self.get_or_create(wiki:, name:, depth:, source:)
+ if source == 'pileid'
+ get_or_create_by_pileid(wiki:, name:, depth:, source:)
+ else
+ find_or_create_by(wiki:, name:, depth:, source:)
+ end
+ end
+
+ def self.get_or_create_by_pileid(wiki:, name:, depth:, source:)
+ # For pagepile records, the name should be unique. Depth
+ # is not applicable, and wiki gets set via PagePileApi if it
+ # doesn't match.
+ record = find_by(source:, name:)
+ return record || create(wiki:, name:, depth:, source:)
+ end
+
def self.refresh_categories_for(course, update_service: nil)
# Updating categories only if they were last updated since
# more than a day, or those which are newly created
diff --git a/app/services/add_sandbox_template.rb b/app/services/add_sandbox_template.rb
index e1f181e37b..7ee90a62ad 100644
--- a/app/services/add_sandbox_template.rb
+++ b/app/services/add_sandbox_template.rb
@@ -19,13 +19,8 @@ def initialize(home_wiki:, sandbox:, sandbox_template:, current_user:)
def add_template
# Never double-post the sandbox template
- if sandbox_template_present?
- return
- elsif default_template_present?
- replace_default_with_sandbox_template
- else
- add_sandbox_template
- end
+ return if sandbox_template_present?
+ default_template_present? ? replace_default_with_sandbox_template : add_sandbox_template
end
def sandbox_template_present?
diff --git a/app/services/sandbox_url_updator.rb b/app/services/sandbox_url_updator.rb
index 2921a5e883..bc7dd4ac64 100644
--- a/app/services/sandbox_url_updator.rb
+++ b/app/services/sandbox_url_updator.rb
@@ -32,8 +32,11 @@ def validate_new_url
raise InvalidUrlError, I18n.t('assignments.invalid_url', url: @new_url) unless new_url_match
# Handle mismatched wiki
new_language, new_project = new_url_match.captures
- unless existing_language == new_language && existing_project == new_project
- raise MismatchedWikiError, I18n.t('assignments.mismatched_wiki', url: @new_url)
- end
+ wiki_matches = (existing_language == new_language && existing_project == new_project)
+ handle_mismatched_wiki unless wiki_matches
+ end
+
+ def handle_mismatched_wiki
+ raise MismatchedWikiError, I18n.t('assignments.mismatched_wiki', url: @new_url)
end
end
diff --git a/app/services/system_metrics.rb b/app/services/system_metrics.rb
new file mode 100644
index 0000000000..bb688fc09c
--- /dev/null
+++ b/app/services/system_metrics.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'sidekiq/api'
+
+class SystemMetrics
+ def initialize
+ # 'very_long_update' queue is excluded as it is intentionally never processed
+ @queues = YAML.load_file('config/sidekiq.yml')[:queues]
+ .reject { |queue_name| queue_name == 'very_long_update' }
+ fetch_sidekiq_stats
+ end
+
+ def fetch_sidekiq_stats
+ stats = Sidekiq::Stats.new
+ {
+ enqueued_jobs: stats.enqueued,
+ active_jobs: stats.processes_size
+ }
+ end
+
+ def fetch_queue_management_metrics
+ queues = []
+ paused_queues = []
+ all_operational = true
+
+ @queues.each do |queue_name|
+ queue = Sidekiq::Queue.new(queue_name)
+ queues << get_queue_data(queue)
+
+ if queue.paused?
+ all_operational = false
+ paused_queues << queue_name
+ end
+ end
+
+ {
+ queues:,
+ paused_queues:,
+ all_operational:
+ }
+ end
+
+ def get_queue_data(queue)
+ {
+ name: queue.name,
+ size: queue.size,
+ status: get_queue_status(queue.name, queue.latency),
+ latency: convert_latency(queue.latency)
+ }
+ end
+
+ LATENCY_THRESHOLDS = {
+ 'default' => 1,
+ 'short_update' => 2.hours,
+ 'medium_update' => 12.hours,
+ 'long_update' => 1.day,
+ 'daily_update' => 1.day,
+ 'constant_update' => 15.minutes
+ }.freeze
+
+ def get_queue_status(queue_name, latency)
+ threshold = LATENCY_THRESHOLDS[queue_name]
+ latency < threshold ? 'Normal' : 'Backlogged'
+ end
+
+ def convert_latency(seconds)
+ case seconds
+ when 0...60
+ "#{seconds.to_i} second#{'s' unless seconds == 1}"
+ when 60...3600
+ format_time(seconds, 60, 'minute', 'second')
+ when 3600...86400
+ format_time(seconds, 3600, 'hour', 'minute')
+ else
+ format_time(seconds, 86400, 'day', 'hour')
+ end
+ end
+
+ def format_time(seconds, unit, main_unit_name, sub_unit_name)
+ main_unit = (seconds / unit).to_i
+ remaining_seconds = (seconds % unit).to_i
+ result = "#{main_unit} #{main_unit_name}#{'s' unless main_unit == 1}"
+ if remaining_seconds.positive?
+ sub_unit, sub_unit_name = case main_unit_name
+ when 'day'
+ [3600, 'hour']
+ when 'hour'
+ [60, 'minute']
+ else
+ [1, 'second']
+ end
+ sub_unit_value = (remaining_seconds / sub_unit).to_i
+ result += " #{sub_unit_value} #{sub_unit_name}#{'s' unless sub_unit_value == 1}"
+ end
+ result
+ end
+end
diff --git a/app/views/campaigns/overview.html.haml b/app/views/campaigns/overview.html.haml
index 55bdf5fd3b..6fc3d84788 100644
--- a/app/views/campaigns/overview.html.haml
+++ b/app/views/campaigns/overview.html.haml
@@ -39,14 +39,22 @@
%button.button.dark
= t('editable.edit')
- if (current_user&.admin?)
+
- if (!@campaign.register_accounts)
- = form_tag("/requested_accounts_campaigns/#{@campaign.slug}/enable_account_requests", method: :put, class: 'campaign-create') do
- %button.button.dark
- = t('campaign.enable_account_requests')
+ .tooltip-trigger
+ = form_tag("/requested_accounts_campaigns/#{@campaign.slug}/enable_account_requests", method: :put, class: 'campaign-create') do
+ %button.button.dark
+ = t('campaign.enable_account_requests')
+ .tooltip.dark
+ %p= t('campaign.enable_account_requests_doc')
- else
- = form_tag("/requested_accounts_campaigns/#{@campaign.slug}/disable_account_requests", method: :put, class: 'campaign-create') do
- %button.button.dark
- = t('campaign.disable_account_requests')
+ .tooltip-trigger
+ = form_tag("/requested_accounts_campaigns/#{@campaign.slug}/disable_account_requests", method: :put, class: 'campaign-create') do
+ %button.button.dark
+ = t('campaign.disable_account_requests')
+ .tooltip.dark
+ %p= t('campaign.enable_account_requests_doc')
+
- if (current_user&.admin? && @campaign.requested_accounts.any?)
= form_tag("/requested_accounts_campaigns/#{@campaign.slug}", method: :get, class: 'campaign-create') do
%button.button.dark
diff --git a/app/views/enrollment_reminder_mailer/email.html.haml b/app/views/enrollment_reminder_mailer/email.html.haml
new file mode 100644
index 0000000000..4e74f66e2c
--- /dev/null
+++ b/app/views/enrollment_reminder_mailer/email.html.haml
@@ -0,0 +1,27 @@
+%link{rel: 'stylesheet', href:'/mailer.css'}
+%table.row
+ %tbody
+ %tr
+ %th
+ %table
+ %tr
+ %td.main-content
+ %p.paragraph
+ = "Hello #{@user.username}!"
+ %p.paragraph
+ Welcome to
+ %a.link{:href => "https://#{ENV['dashboard_url']}"}
+ Wiki Education Dashboard!
+ %p.paragraph
+ You've signed in to the Dashboard using your Wikipedia account, but
+ you haven't joined a course. If you are a student and you were given
+ an enrollment link by your instructor, you can now use that link
+ to add yourself to the course page.
+ %p.paragraph
+ If you're here for another reason, or you've run into technical difficulties,
+ let us know by replying to this email.
+ %p.paragraph
+ Best regards,
+ %br
+ %em
+ The Wiki Education team
\ No newline at end of file
diff --git a/app/views/layouts/surveys.html.haml b/app/views/layouts/surveys.html.haml
index 1147ec2ad1..4ee74b12d5 100644
--- a/app/views/layouts/surveys.html.haml
+++ b/app/views/layouts/surveys.html.haml
@@ -20,6 +20,6 @@
// survey.js sometimes interferes with the rendering of results.
= javascript_include_tag '/assets/javascripts/jquery.min.js'
= hot_javascript_tag("survey") unless page_class == 'surveys results'
- - if can_administer?
+ - if can_administer? && !params.key?("preview")
= hot_javascript_tag("survey_admin")
= content_for :additional_javascripts
diff --git a/app/views/personal_data/show.json.jbuilder b/app/views/personal_data/show.json.jbuilder
index 6ae3bd50ba..ffbfaa721e 100644
--- a/app/views/personal_data/show.json.jbuilder
+++ b/app/views/personal_data/show.json.jbuilder
@@ -25,7 +25,7 @@ end
json.campaigns do
json.array! @user.campaigns_users.includes(:campaign).each do |campaign_user|
- json.campaign campaign.user.campaign.slug
+ json.campaign campaign_user.campaign.slug
json.joined_at campaign_user.created_at
end
end
diff --git a/app/views/styleguide/_nav.html.haml b/app/views/styleguide/_nav.html.haml
index ef3102d0b7..07d0f8696c 100644
--- a/app/views/styleguide/_nav.html.haml
+++ b/app/views/styleguide/_nav.html.haml
@@ -11,8 +11,6 @@
%a{href: "#formcontrols"}Form Controls
%li
%a{href: "#forms"}Forms
- %li
- %a{href: "#dialogs"}Dialogs
%li
%a{href: "#popovers"}Popovers
%li
diff --git a/app/views/system_status/index.html.haml b/app/views/system_status/index.html.haml
new file mode 100644
index 0000000000..1ce0c5ee70
--- /dev/null
+++ b/app/views/system_status/index.html.haml
@@ -0,0 +1,67 @@
+.container.queues
+ .module
+ .section-header
+ %h3= t("status.queues_overview")
+ .notification
+ .container
+ - if @queue_metrics[:all_operational]
+ %p= t("status.all_queues_operational")
+ - else
+ %p= t("status.all_queues_not_operational")
+
+ .notifications
+ .notice
+ .container
+ - @queue_metrics[:paused_queues].each do |queue_name|
+ %p= queue_name.humanize
+ %br/
+
+ %table.table.table--hoverable
+ %thead
+ %tr
+ %th= t("status.queue")
+ %th= t("status.purpose")
+ %th= t("status.status")
+ %th
+ .tooltip-trigger
+ = t("status.size")
+ %span.tooltip-indicator
+ .tooltip.dark
+ %p= t("status.size_doc")
+ %th
+ .tooltip-trigger
+ = t("status.latency")
+ %span.tooltip-indicator
+ .tooltip.dark
+ %p= t("status.latency_doc")
+ %tbody
+ - @queue_metrics[:queues].each do |queue|
+ %tr{ class: queue[:status] == "Normal" ? "table-row--success" : "table-row--warning" }
+ %td
+ .tooltip-trigger
+ = t("status.#{queue[:name]}")
+ %span.tooltip-indicator
+ .tooltip.dark
+ %p= t("status.#{queue[:name]}_description")
+ %td= t("status.#{queue[:name]}_doc")
+ %td= queue[:status]
+ %td= queue[:size]
+ %td= queue[:latency]
+
+.container.sidekiq_stats
+ %br/
+ %h3= t("status.sidekiq_stats")
+ .stat-display
+ - @sidekiq_stats.each do |key, value|
+ .stat-display__stat.tooltip-trigger
+ .stat-display__value= value
+ %small= key.to_s.humanize
+
+ .tooltip.dark
+ - case key
+ - when :enqueued_jobs
+ %p= t("status.enqueued_jobs_doc")
+ - when :active_jobs
+ %p= t("status.active_jobs_doc")
+ - else
+ %p= t("status.no_info")
diff --git a/app/views/user_profiles/show.html.haml b/app/views/user_profiles/show.html.haml
index af61d73ea4..1885fbc892 100644
--- a/app/views/user_profiles/show.html.haml
+++ b/app/views/user_profiles/show.html.haml
@@ -21,4 +21,11 @@
- if @user == current_user
.personal_data
%small
- = link_to 'Download personal data', '/download_personal_data.json'
+ = 'Download personal data as'
+ %small
+ = link_to 'json', '/download_personal_data.json'
+ %small
+ = 'or'
+ %small
+ = link_to 'csv', '/download_personal_data_csv'
+
diff --git a/app/workers/enrollment_reminder_email_worker.rb b/app/workers/enrollment_reminder_email_worker.rb
new file mode 100644
index 0000000000..cee1b824b2
--- /dev/null
+++ b/app/workers/enrollment_reminder_email_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class EnrollmentReminderEmailWorker
+ include Sidekiq::Worker
+ sidekiq_options lock: :until_executed,
+ retry: 0 # Move job to the 'dead' queue if it fails
+
+ def self.schedule_reminder(user)
+ # We only use this for users who didn't indicate they are instructors,
+ # as it is intended to prompt students to use the enrollment link
+ # in case something in the enrollment flow went wrong.
+ return unless user.permissions == User::Permissions::NONE
+ # Also make sure we don't spam people who revisit the onboarding flow.
+ return if user.created_at < 1.day.ago
+
+ perform_at(5.minutes.from_now, user.id)
+ end
+
+ def perform(user_id)
+ user = User.find user_id
+ return if user.courses.any?
+
+ EnrollmentReminderMailer.send_reminder(user)
+ end
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 086d2a3ccc..3900247e72 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -54,6 +54,8 @@ def dump_js_coverage
# Settings specified here will take
# precedence over those in config/application.rb.
+ Paperclip.options[:command_path] = "/usr/bin"
+
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 23338293c9..97f7f6571c 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -6,6 +6,7 @@
# Author: Alshamiri1
# Author: Ayatun
# Author: Azouz.anis
+# Author: Cigaryno
# Author: Dr. Mohammed
# Author: Eyas
# Author: FShbib
@@ -22,6 +23,7 @@
# Author: Shbib Al-Subaie
# Author: Sonic N800
# Author: Tala Ali
+# Author: XIDME
# Author: Youssef
# Author: أَحمد
# Author: ديفيد
@@ -45,7 +47,7 @@ ar:
المدخلات.
application:
back: رجوع
- cancel: ألغ
+ cancel: إلغاء
cookie_consent: يستخدم هذا الموقع ملفات تعريف الارتباط للحفاظ على تسجيل الدخول
وتخزين المعلومات المتعلقة بحساب ويكيبيديا الخاص بك.
cookie_consent_acknowledge: أفهم
@@ -1691,4 +1693,9 @@ ar:
assignment_status: مهمة التدريب (%{status})
multi_wiki:
selector_placeholder: ابدأ في كتابة نطاق الويكي
+ weekday_picker:
+ aria:
+ weekday_select: '{{weekday}} اضغط على مفتاح الرجوع للتحديد'
+ weekday_selected: '{{weekday}} تم ضغط على مفتاح الرجوع لإلغاء التحديد'
+ weekday_unselected: '{{weekday}} غير محدد'
...
diff --git a/config/locales/dga.yml b/config/locales/dga.yml
index 4c21e31542..0893076a14 100644
--- a/config/locales/dga.yml
+++ b/config/locales/dga.yml
@@ -1,4 +1,4 @@
-# Messages for Dagaare (Dagaare)
+# Messages for Southern Dagaare (Dagaare)
# Exported from translatewiki.net
# Export driver: phpyaml
# Author: Domo John
diff --git a/config/locales/diq.yml b/config/locales/diq.yml
index 4df764bb3a..1de5700b3e 100644
--- a/config/locales/diq.yml
+++ b/config/locales/diq.yml
@@ -1,4 +1,4 @@
-# Messages for Zazaki (Zazaki)
+# Messages for Dimli (Zazaki)
# Exported from translatewiki.net
# Export driver: phpyaml
# Author: 1917 Ekim Devrimi
@@ -268,6 +268,7 @@ diq:
tickets:
none: Bıleti çıniyê
timeline:
+ done: Temam
empty_week_3: ya zi
week_number: Hefte %{number}
training:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ba7dd1307b..acf94679c6 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -412,6 +412,7 @@ en:
delete_course_tooltip: Delete and remove from the campaign.
disable_account_requests: Disable account requests
enable_account_requests: Enable account requests
+ enable_account_requests_doc: The account requests feature adds an account request form to the enrollment link for an event. Users who don't have accounts yet can enter their email and desired username, and event facilitators can use the Dashboard to create the requested accounts. Users who request accounts are automatically added to the event upon account creation.
newest_campaigns: Newest Campaigns
featured_campaigns: Featured Campaigns
instructors_course: instructors by course
@@ -1294,6 +1295,42 @@ en:
already_is_not: "%{username} is not a Special User!"
demote_success: "%{username} is now just a user."
+ status:
+ active_jobs: Active jobs
+ active_jobs_doc: The number of currently processing jobs.
+ all_queues_operational: All Queues Operational
+ all_queues_not_operational: All Queues Operational except
+ constant_update: Constant update
+ constant_update_description: Constant updates are independent of the main course stats, pulling in revision metadata, generating alerts, and doing other data and network-intensive tasks, for all current courses.
+ constant_update_doc: Handles transactional jobs like wiki edits and sending email.
+ daily_update: Daily update
+ daily_update_description: This pulls in additional data and performs other tasks that do not need to be done many times per day.
+ daily_update_doc: Handles once-daily long-running data update tasks.
+ default: Default
+ default_description: Schedule course updates by sorting courses into queues depending on how long they run.
+ default_doc: Handles frequently-run tasks like adding courses to the update queues.
+ enqueued_jobs: Enqueued jobs
+ enqueued_jobs_doc: The number of currently enqueued jobs in all queues.
+ latency: Latency
+ latency_doc: The waiting time for jobs to start processing in the queue. High latency may indicate a busy system or processing delays.
+ long_update: Long update
+ long_update_description: Long updates process courses with more than 10,000 revisions.
+ long_update_doc: Handles updates for large courses.
+ medium_update: Medium update
+ medium_update_description: Medium updates process courses with fewer than 10,000 revisions.
+ medium_update_doc: Handles updates for typical-sized courses.
+ no_info: No info available.
+ purpose: Purpose
+ queues_overview: Queues Overview
+ queue: Queue
+ short_update: Short update
+ short_update_description: Short updates process courses with fewer than 1,000 revisions.
+ short_update_doc: Handles updates for small courses.
+ sidekiq_stats: Sidekiq Stats
+ size: Size
+ size_doc: The number of jobs within a queue.
+ status: Status
+
# Suggestions source: https://en.wikipedia.org/wiki/Template:Grading_scheme
suggestions:
editing: Editing Suggestions
@@ -1352,6 +1389,7 @@ en:
delete_weeks_confirmation: "Are you sure you want to delete this entire timeline and start over? \n\nThis cannot be undone."
discard_all_changes: Discard All Changes
delete_timeline_and_start_over: Delete Timeline
+ done: Done
due_default: Due next week
edit_titles: Edit Week Titles
edit_titles_info: Edit week titles in timeline. Only weeks with scheduled meetings can be given custom titles. If any custom titles are used, weeks with no meetings will be dated instead of numbered.
@@ -1621,6 +1659,7 @@ en:
assignments. Click the date to change between unselected, selected, and
holiday state. If you have no holidays, check "I have no class holidays"
below.
+ confirm_course_dates: Confirm the course's start and end dates.
confirm_dates: Choose the course dates, weekly meetings, and holidays for your course.
course_dates: Course Dates
min_weeks: >
@@ -1631,6 +1670,7 @@ en:
minimum_options:
one: Please select %{count} or more options.
other: Please select %{count} or more options.
+ no_class_holidays: I have no class holidays
read_less: Read Less
read_more: Read More
review_selections: >
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 3d6c23c6dd..ace62afa0f 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -1790,7 +1790,7 @@ fr:
user_training_status: État de la formation
user_no_training_status: Cet utilisateur n’a terminé aucun des modules de formation.
up_to_date_with_training: formation à jour
- uploads_doc: Nombre de fichiers téléversés sur Wikimédia Commons
+ uploads_doc: Nombre de fichiers téléversés sur Wikimedia Commons
ungreeted: Pas le bienvenu
username: Nom d’utilisateur
username_placeholder: Nom d’utilisateur
@@ -2113,4 +2113,10 @@ fr:
de serveur. Veuillez réessayer plus tard.
tagged_courses:
download_csv: Télécharger en CSV
+ weekday_picker:
+ aria:
+ weekday_select: '{{weekday}} Appuyez sur la touche Entrée pour sélectionner'
+ weekday_selected: '{{weekday}} sélectionné, Appuyez sur la touche Entrée pour
+ désélectionner'
+ weekday_unselected: '{{weekday}} non sélectionné'
...
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index f862094a76..6f09a8808d 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -423,6 +423,7 @@ hi:
new:
form_placeholder: सदस्यनाम
timeline:
+ done: पूर्ण हुआ
empty_week_3: या
training:
next: अगला पृष्ठ
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index b3ee847964..ca36d5bc76 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -363,6 +363,9 @@ hy:
special_users:
new:
form_placeholder: Օգտանուն
+ status:
+ size: Չափ
+ status: Կարգավիճակ
suggestions:
suggestion_docs:
does_not_exist: 'Այս հոդվածը Վիքիպեդիայում գոյություն չունի:'
@@ -370,6 +373,7 @@ hy:
add_week: Ավելացնել շաբաթ
arrange_timeline: Կազմակերպել ժամանակացույցը
block_custom: Կարգավորվող
+ done: Արված է
empty_week_3: կամ
this_week_title_default: Այս շաբաթ
training:
diff --git a/config/locales/io.yml b/config/locales/io.yml
index d68bf6c711..d9c490af5a 100644
--- a/config/locales/io.yml
+++ b/config/locales/io.yml
@@ -282,6 +282,7 @@ io:
new:
form_placeholder: Nomo dil uzero
timeline:
+ block_custom: Ajustita
gradeable_value: Valoro
week_number: Semano %{number}
training:
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index e462c2f4e4..9c8905ce1c 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -754,6 +754,14 @@ ko:
elevate_success: '%{username}님은 {position}(으)로 승격되었습니다.'
remove:
already_is_not: '%{username}님은 특수 사용자가 아닙니다!'
+ status:
+ daily_update: 일일 업데이트
+ default: 기본값
+ latency: 레이턴시
+ no_info: 정보가 없습니다.
+ purpose: 목적
+ size: 크기
+ status: 상태
suggestions:
editing: 편집 제안
suggestion_docs:
@@ -775,6 +783,7 @@ ko:
이 작업은 되돌릴 수 없습니다.
discard_all_changes: 모든 바뀜 취소
delete_timeline_and_start_over: 타임라인 삭제
+ done: 완료
due_default: 다음 주까지
empty_week_1: 시작하려면,
empty_week_3: 또는
diff --git a/config/locales/ky.yml b/config/locales/ky.yml
index 33e219c871..22abfd4cf9 100644
--- a/config/locales/ky.yml
+++ b/config/locales/ky.yml
@@ -8,6 +8,7 @@ ky:
cancel: Жокко чыгаруу
greeting2: Кош келиңиз !
help: Жардам
+ log_in_extended: Википедия аркылуу кирүү
log_in: Кирүү
log_out: Чыгуу
sign_up: Катталуу
diff --git a/config/locales/lb.yml b/config/locales/lb.yml
index 5e78823525..033939107e 100644
--- a/config/locales/lb.yml
+++ b/config/locales/lb.yml
@@ -29,7 +29,7 @@ lb:
error: 'Et gouf e Feeler:'
explore: Entdecken
field_required: Dëst Feld ass obligatoresch
- field_invalid_characters: Dëst Feld huet net valabel Zeechen
+ field_invalid_characters: Dëst Feld enthält ongülteg Zeechen
field_invalid_date: Dëst Feld brauch en Datum am Format JJJJ-MM-DD
field_invalid_date_time: De Schluss muss nom Ufank sinn. (Stonne gi vun 0 bis
23.)
@@ -462,7 +462,7 @@ lb:
next_update: Nächst erwaart Aktualiséierung
activity: Rezent Aktivitéit
articles_edited: Geännert Artikelen
- bytes_added: Byten derbäigesat
+ bytes_added: Byte derbäigesat
characters: Buschtawen
char_changed: Geännert Buschtawen
close_modal: Zoumaachen
diff --git a/config/locales/pa.yml b/config/locales/pa.yml
index fc8e8430a1..0b17dfa9d5 100644
--- a/config/locales/pa.yml
+++ b/config/locales/pa.yml
@@ -44,7 +44,7 @@ pa:
sign_up_wikipedia: ਜਾਂ ਵਿਕੀਪੀਡੀਆ 'ਤੇ ਸਾਈਨ ਅੱਪ ਕਰੋ
sign_up_log_in_extended: (ਵਿਕੀਪੀਡੀਆ ਨਾਲ)
sign_up: ਖਾਤਾ ਬਣਾਓ
- submit: ਸਪੁਰਦ ਕਰੋ
+ submit: ਹਵਾਲੇ ਕਰੋ
training: ਸਿਖਲਾਈ
change: ਬਦਲੋ
details: ਵੇਰਵੇ
@@ -250,7 +250,7 @@ pa:
show_options: ਵਿਕਲਪ ਦਿਖਾਓ
subheading_message: ਆਓ ਕੰਮ ਕਰਨ ਲਈ ਇੱਕ ਵਿਕੀਪੀਡੀਆ ਲੇਖ ਲੱਭੀਏ।
subheading_message_wikidata: ਆਓ ਕੰਮ ਕਰਨ ਲਈ ਇੱਕ ਵਿਕੀਡਾਟਾ ਆਈਟਮ ਲੱਭੀਏ।
- submit: ਸਪੁਰਦ ਕਰੋ
+ submit: ਹਵਾਲੇ ਕਰੋ
tools: ਸੰਦ
training_status:
completed: ਪੂਰਾ ਹੋਇਆ
diff --git a/config/locales/ps.yml b/config/locales/ps.yml
index 9a7e447043..77aa3416a1 100644
--- a/config/locales/ps.yml
+++ b/config/locales/ps.yml
@@ -100,7 +100,7 @@ ps:
student_editors: زده کوونکي سمونگران
students_none: دا کورس کوم زدکړيال نه لري.
students_short: زده کړيالان
- timeline_link: وختکرښه
+ timeline_link: وختکرښه
title: سرليک
view: کورس کتل
view_page: د کورس مخ کتل
@@ -153,7 +153,7 @@ ps:
gradeable_value: ارزښت
save_all_changes: ټول خوندي کول
this_week_title_default: دا اونۍ
- title: وختکرښه
+ title: وختکرښه
week_number: اونۍ %{number}
training:
start: پيلول
@@ -190,4 +190,7 @@ ps:
email:
headline: سلامونه، %{username}
course: 'کورس: %{course_info}'
+ weekday_picker:
+ aria:
+ weekday_unselected: '{{د اونۍ ورځ}} ناټاکلی'
...
diff --git a/config/locales/qqq.yml b/config/locales/qqq.yml
index bbbb6b3a89..1dbe59d26d 100644
--- a/config/locales/qqq.yml
+++ b/config/locales/qqq.yml
@@ -285,6 +285,8 @@ qqq:
Label for the description field when creating a new campaign
{{Identical|Description}}
delete_campaign: Button to delete a campaign
+ enable_account_requests_doc: Tooltip explaining what the 'Enable Account Requests'
+ button does
none: |-
Option for a course with no campaign
{{Identical|None}}
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 6658486bbc..40c269ebcf 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -637,6 +637,9 @@ ru:
special_users:
new:
form_placeholder: Имя участника
+ status:
+ size: Размер
+ status: Статус
tasks:
conseq: Задача %{task} уже выполняется!
constant: Задача %{task} не может быть выполнена, когда происходит ежедневное
@@ -646,6 +649,7 @@ ru:
timeline:
arrange_timeline: Настроить расписание
delete_timeline_and_start_over: Удалить расписание
+ done: Готово
empty_week_3: или
gradeable_value: Значение
no_timeline: У этого курса пока нет расписания.
diff --git a/config/locales/se.yml b/config/locales/se.yml
index 43f62f073d..603720a472 100644
--- a/config/locales/se.yml
+++ b/config/locales/se.yml
@@ -62,6 +62,7 @@ se:
loading: Viežžamin bargguid...
remove: Sihko
select: Vállje
+ submit: Sádde
campaign:
alert_label: Kampánjja várrehusat
alert_user_id: Geavaheaddji
@@ -104,6 +105,7 @@ se:
creator:
save_cloned_course: Vurke
scoping_methods:
+ categories: Kategoriijat
templates: Mállet
student_editors: Geavaheaddjit
students: Geavaheaddjit
@@ -124,6 +126,7 @@ se:
article: Artihkal
file: Fiila
page: Siidu
+ book: Girji
recent_activity:
file_name: Fiilanamma
revisions:
@@ -157,7 +160,9 @@ se:
update_username:
label: Ođđa geavaheaddjinamma
uploads:
+ categories: Kategoriijat
file_name: Fiilanamma
+ license: 'Liseansa:'
users:
editors: Geavaheddjiid mearri
first_name: Ovdanamma
@@ -177,4 +182,6 @@ se:
training_status:
continue: Joatkke
view: Čájet
+ notes:
+ save_note: Ruõkk
...
diff --git a/config/locales/skr-arab.yml b/config/locales/skr-arab.yml
index 2a3e004835..2755f78c89 100644
--- a/config/locales/skr-arab.yml
+++ b/config/locales/skr-arab.yml
@@ -253,6 +253,12 @@ skr-arab:
special_users:
new:
form_placeholder: ورتݨ ناں
+ status:
+ default: پہلے کنوں طے تھیا ہویا
+ purpose: مقصد
+ queue: قطار
+ size: سائز
+ status: حیثیت
timeline:
add_week: ہفتہ شامل کرو
block_milestone: سنگ میل
@@ -260,6 +266,7 @@ skr-arab:
course_end: کورس دا چھیکڑ
course_start: کورس شروع
delete_timeline_and_start_over: ٹائم لائن مٹاؤ
+ done: تھی ڳیا
empty_week_1: شروع کرو
empty_week_3: یا
gradeable_value: قدر
diff --git a/config/locales/smn.yml b/config/locales/smn.yml
index 177f3ba6bd..302e410736 100644
--- a/config/locales/smn.yml
+++ b/config/locales/smn.yml
@@ -64,6 +64,7 @@ smn:
article_link: Artikkâl
add_available_submit: Lasseet artikkâlijd
remove: Siho
+ submit: Vuolgât
campaign:
alert_user_id: Kevttee
description: Kuvvim
@@ -80,6 +81,7 @@ smn:
download_stats_data: Luođii lovottuvâid
feedback: Macâttâs
home_wiki: Päikkiwiki
+ loading: Luođiimin…
new_account_email: Šleđgâpostâčujottâs
overview: Päikki
please_log_in: Čáládât siisâ.
@@ -93,6 +95,7 @@ smn:
creator:
save_cloned_course: Vuorkkii
scoping_methods:
+ categories: Luokah
templates: Myenstereh
editable:
cancel: Jooskâ
@@ -105,6 +108,7 @@ smn:
upload_count: Vuorkkiimeh Commonsin
removed: Sikkum
namespace:
+ book: Kirje
lexeme: Lekseem
recent_activity:
image: Kove
@@ -133,7 +137,9 @@ smn:
update_username:
label: Uđđâ kevtteenommâ
uploads:
+ categories: Luokah
image: Kove
+ license: 'Liiseens:'
users:
first_name: Ovdânommâ
last_name: Suhânommâ
diff --git a/config/locales/sms.yml b/config/locales/sms.yml
index c2bbfe3f51..aa6984052d 100644
--- a/config/locales/sms.yml
+++ b/config/locales/sms.yml
@@ -55,8 +55,10 @@ sms:
article_link: Artikkel
add_available_submit: Lââʹzzet artikkeeʹlid
bibliography: Bibliografia
+ confirm_add_available: Haaʹlääk-a ton tuõđi artikkeeʹl %{title} lââʹzzted?
remove: Jaukkâd
select: Vaʹlljed
+ submit: Vuõlttâd
campaign:
alert_article: Artikkeeʹl nõmm
alert_user_id: Õõʹnni
@@ -76,9 +78,9 @@ sms:
add_category: Lââʹzzet kategoria
add_psid: Lââʹzzet PetScan PSID
add_template: Lââʹzzet maall
- add_this_category: Lââʹzzet tän kategoria
+ add_this_category: Lââʹzzet kategoriaid
add_this_psid: Lââʹzzet tän PSID
- add_this_template: Lââʹzzet tän maall
+ add_this_template: Lââʹzzet maallid
articles_count: Artikkelmieʹrr
name: Nõmm
category_name: Kategorianõmm
@@ -143,6 +145,7 @@ sms:
find: Ooʒʒ prograamm
save_cloned_course: Ruõkk
scoping_methods:
+ categories: Kategoria
templates: Maall
delete_course: Jaukkâd prograamm
explore: Ooʒʒ prograammi
@@ -167,6 +170,7 @@ sms:
project: Projeʹktt
file: Teâttõs
category: Kategoria
+ book: Ǩeʹrjj
recent_activity:
article_title: Nõmm
image: Kaart da snimldõõǥǥ
@@ -206,15 +210,18 @@ sms:
update_username:
label: Ođđ õõʹnninõmm
uploads:
+ categories: Kategoria
file_name: Teâttõsnõmm
image: Kartt leʼbe snimldõk
label: Ruõkkmõõžž
+ license: 'Liseʹnss:'
uploaded_by: 'Ruõkkâm:'
users:
contributions: Õõʹnni muttâz
contribution_statistics: Muʹttemstatistiikk
course_passcode: 'Peittsääʹnn:'
edits: muttâz
+ enroll_confirmation: Haaʹlääk-a ton tuõđi õõʹnni %{username} lââʹzzted?
first_name: Risttnõmm
last_name: Sokknõmm
name: Nõmm
@@ -224,6 +231,7 @@ sms:
one: '%{count} artikkel'
other: '%{count} artikkeeʹl'
references_count: Lââʹzztum teâttkääiv
+ remove_confirmation: Haaʹlääk-a ton tuõđi õõʹnni %{username} jaukkeed?
training_module_status: Status
username: Õõʹnninõmm
username_placeholder: Õõʹnninõmm
diff --git a/config/locales/sr-ec.yml b/config/locales/sr-ec.yml
index 34ecafa6b2..e86b372bf4 100644
--- a/config/locales/sr-ec.yml
+++ b/config/locales/sr-ec.yml
@@ -895,6 +895,10 @@ sr-ec:
revoking_button_working: Опозивање…
already_is_not: Корисник %{username} није посебан!
demote_success: '%{username} је сада само корисник.'
+ status:
+ no_info: Нема података.
+ purpose: Сврха
+ status: Статус
suggestions:
editing: Уређивање предлога
suggestion_docs:
@@ -969,6 +973,7 @@ sr-ec:
оквир и да почнете поново? Овај корак не може да буде опозван.
discard_all_changes: Одбаци све промене
delete_timeline_and_start_over: Избриши хронологију
+ done: Урађено
due_default: Рок је следећа недеља
empty_week_1: За почетак,
empty_week_2: започни уређивање ове недеље
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 3269ec2e79..873520289f 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -11,11 +11,13 @@
# Author: Reke
# Author: S8321414
# Author: Shangkuanlc
+# Author: SupaplexTW
# Author: Wehwei
# Author: Winston Sung
# Author: Wwycheuk
# Author: Xiplus
# Author: 列维劳德
+# Author: 張詠涵
# Author: 捍粵者
---
zh-TW:
@@ -413,6 +415,7 @@ zh-TW:
active_courses: 啟動課程
add_trainings: 請添加學生培訓至您分配到的時間軸。分配培訓模組是 Wiki 教育最佳實踐的重要部分。
alerts: 提醒
+ does_not_exist: 該項目不存在。請建立一個沙盒頁面並分配給您自己來接收建議。
all_courses: 所有課程
already_enrolled: 您已經成為「%{title}」課程的一份子!
already_exists: 已存在於該課程中!
@@ -513,7 +516,7 @@ zh-TW:
find: 查找您的課程
intro: 您所選擇的課程標題、學校、和學期將會構成您課程頁面的 URL,並且不可更改。除了以上三者之外,其餘項目皆可稍後編輯。點選 「建立我的課程!」
開始規劃您的維基百科分配作業時間軸。
- new_or_clone: 您現在可透過分配事項精靈來創建新的課程、如果您擁有來自先前課程的高度自定義時間軸而想重新使用,您可以使用新的日期來複製複本(如果您先前沒有明確自定義您的時間軸,我們建議您先開始一個課程,以便擁有最新版本的分配作業選項。)。您也可以使用複製選項來替您先前設定好的課程創建額外部份。
+ new_or_clone: 點'創建新的課程'來透過分配事項精靈來創建新的課程,你的時間軸會包括維基教育專案推薦訓練模組的最新選項,以及其他資源。如果您擁有來自先前課程的高度自定義時間軸而想重新使用,您可以使用新的日期來複製複本。您也可以使用複製選項來替您先前設定好的課程創建額外部份。
no_class_holidays: 我的課程沒有遭遇假日
role_description: 您在課程裡的身份
role_description_options:
@@ -745,6 +748,7 @@ zh-TW:
write_subject: 主旨
write_message_placeholder: 訊息
sending_notification: 寄送中
+ bcc_to_salesforce: 密件副本至 Salesforce
courses_generic:
school_system: 學院系統
add_campaign: 新增競賽
@@ -861,7 +865,7 @@ zh-TW:
uploads_none: 此專案沒有提供任何圖片或媒體檔案至維基共享資源裡。
user_uploads_none: 所選使用者尚未對此專案貢獻任何圖片或其它多媒體檔案。
view_other: 檢視其它競賽
- view_page: 檢視方案頁面
+ view_page: 檢視頁面
word_count_doc: 一個由條目編輯者在方案學期期間增加至條目主空間的估計數量
word_count_doc_wikidata: 一個由條目編輯者在方案學期期間增加至主空間項目的估計字詞數量
yourcourses: 您的方案
@@ -966,6 +970,8 @@ zh-TW:
other: 跨語言項目使用總計
view_count_description: 條目檢視數
view_count_description_wikidata: 項目查看
+ view_count_doc: 這是根據近期統計更新,平均30天每篇文章的平均瀏覽次數所推估的瀏覽次數。瀏覽次數可能因為更新後的次數比先前少而減少。
+ view_count_doc_wikidata: 這是根據近期統計更新,平均30天每個項目的平均瀏覽次數所推估的瀏覽次數。瀏覽次數可能因為更新後的次數比先前少而減少。
view_data_unavailable: 沒有可用的檢視資料
view: 檢視數
wiki_ed_help: 使用「取得協助」按鈕來回報問題。
@@ -1588,6 +1594,8 @@ zh-TW:
button_text: 管理註釋
header_text: 管理註釋面板
aria_label:
+ no_notes_available: 面板中沒有可用的管理註解。按返回鍵開啟管理註解面板。
+ new_notes_message: '%{count} 個新的管理員註釋可用。'
notes_panel_opened: 已開啟管理註釋面板互動視窗。您可以按下退出鍵來關閉。
close_admin: 關閉管理註釋面板互動視窗
note_action_button: 註釋操作按鈕
@@ -1609,4 +1617,9 @@ zh-TW:
JSONP_request_failed: 從維基百科取得資料的請求已逾時。這可能是由於網路連線速度太慢,或是臨時伺服器問題造成的。請稍後重試。
tagged_courses:
download_csv: 下載 CSV
+ weekday_picker:
+ aria:
+ weekday_select: '{{weekday}},按下返回鍵以選擇'
+ weekday_selected: '{{weekday}} 已選擇,按下返回鍵以取消選擇'
+ weekday_unselected: '{{weekday}} 未選擇'
...
diff --git a/config/routes.rb b/config/routes.rb
index af7c002c19..734855b297 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -65,6 +65,7 @@
#PersonalDataController
controller :personal_data do
get 'download_personal_data' => 'personal_data#show'
+ get 'download_personal_data_csv' => 'personal_data#personal_data_csv'
end
# Users
@@ -481,6 +482,8 @@
get '/private_information' => 'about_this_site#private_information'
get '/styleguide' => 'styleguide#index'
+ get '/status' => 'system_status#index'
+
# Errors
match '/404', to: 'errors#file_not_found', via: :all
match '/422', to: 'errors#unprocessable', via: :all
diff --git a/docs/analytics_scripts/no_sandbox_pilot_analysis.rb b/docs/analytics_scripts/no_sandbox_pilot_analysis.rb
new file mode 100644
index 0000000000..9af75337eb
--- /dev/null
+++ b/docs/analytics_scripts/no_sandbox_pilot_analysis.rb
@@ -0,0 +1,38 @@
+# Data for the no-sandbox experiment
+
+
+fall_2024 = Campaign.find_by_slug('fall_2024')
+
+# articles edited, total edit count, mainspace edit count, mainspace bytes added, number of tickets, yes/no sandbox, new/returning, experiment_tag?, control tag?, how hard Q
+
+headers = ['course', 'student count', 'articles edited', 'total edit count', 'mainspace edit count',
+ 'mainspace bytes added', 'ticket count', 'yes or no sandbox', 'new or returning',
+ 'experiment tag', 'how hard Q']
+data = [headers]
+
+fall_2024.courses.each do |c|
+ puts c.slug
+ row = []
+ row << c.slug
+ row << c.user_count
+ row << c.article_count
+ row << c.revisions.count
+ row << c.revisions.joins(:article).where(articles: { namespace: Article::Namespaces::MAINSPACE, deleted: false }).count
+ row << c.character_sum
+ row << c.tickets.count
+ row << (c.no_sandboxes? ? 'no sandboxes' : 'yes sandboxes')
+ row << (c.returning_instructor? ? 'returning' : 'new')
+ experiment_tag = if c.tag?('no_sandbox_fall_2024_experiment_condition')
+ 'no_sandbox_fall_2024_experiment_condition'
+ elsif c.tag?('no_sandbox_fall_2024_control_condition')
+ 'no_sandbox_fall_2024_control_condition'
+ end
+ row << experiment_tag
+ how_hard_answer = Rapidfire::AnswerGroup.find_by(course_id: c.id, question_group_id: 75)&.answers&.where(question_id: 1858)&.first&.answer_text
+ row << how_hard_answer
+ data << row
+end
+
+CSV.open("/home/sage/fall_2024_sandbox_data.csv", 'wb') do |csv|
+ data.each { |line| csv << line }
+end
\ No newline at end of file
diff --git a/docs/analytics_scripts/peony_gap_campaigns.rb b/docs/analytics_scripts/peony_gap_campaigns.rb
new file mode 100644
index 0000000000..90ac59861a
--- /dev/null
+++ b/docs/analytics_scripts/peony_gap_campaigns.rb
@@ -0,0 +1,73 @@
+# Data about Wikigap, A+F and Women in Red events from Peony
+#
+# slug, editors, articles created, articles edited, start, end, tracked wikis, name, institution, campaigns
+#
+# https://outreachdashboard.wmflabs.org/campaigns/wikigap_2018/programs
+# https://outreachdashboard.wmflabs.org/campaigns/campaign_wikigap_2019/programs
+# https://outreachdashboard.wmflabs.org/campaigns/wikigap_2020/programs
+# https://outreachdashboard.wmflabs.org/campaigns/wikigap_2021/programs
+# https://outreachdashboard.wmflabs.org/campaigns/wikigap_2022/programs
+# https://outreachdashboard.wmflabs.org/campaigns/wikigap_2023/programs
+# https://outreachdashboard.wmflabs.org/explore?search=wikigap+2024
+
+
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2018/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2019/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2020/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2021/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2022/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2023/programs
+# https://outreachdashboard.wmflabs.org/campaigns/artfeminism_2024/programs
+
+
+campaign_slugs = [
+ 'wikigap_2018',
+ 'campaign_wikigap_2019',
+ 'wikigap_2020',
+ 'wikigap_2021',
+ 'wikigap_2022',
+ 'wikigap_2023',
+ 'artfeminism_2018',
+ 'artfeminism_2019',
+ 'artfeminism_2020',
+ 'artfeminism_2021',
+ 'artfeminism_2022',
+ 'artfeminism_2023',
+ 'artfeminism_2024'
+]
+
+wikigap_2024_course_ids = [27564, 27660, 27674]
+
+course_ids = wikigap_2024_course_ids
+
+campaign_slugs.each do |slug|
+ ids = Campaign.find_by(slug: slug).courses.nonprivate.map(&:id)
+ course_ids += ids
+end
+
+# 1502 courses, 1483 unique courses
+course_ids.uniq!
+
+headers = %w[slug editors articles_created articles_edited revision_count activity_start activity_end tracked_wikis name institution campaigns]
+data = [headers]
+
+course_ids.each do |cid|
+ c = Course.find(cid)
+ row = []
+ row << c.slug
+ row << c.user_count
+ row << c.new_article_count
+ row << c.article_count
+ row << c.revision_count
+ row << c.start.to_s
+ row << c.end.to_s
+ row << c.wikis.map(&:domain).join(', ')
+ row << c.title
+ row << c.school
+ row << c.campaigns.map(&:slug).join(', ')
+ data << row
+end
+
+CSV.open("/home/ragesoss/mali_data.csv", 'wb') do |csv|
+ data.each { |line| csv << line }
+end
\ No newline at end of file
diff --git a/docs/analytics_scripts/stemm_bios.rb b/docs/analytics_scripts/stemm_bios.rb
new file mode 100644
index 0000000000..ddf7a07508
--- /dev/null
+++ b/docs/analytics_scripts/stemm_bios.rb
@@ -0,0 +1,64 @@
+# Generate a list of article titles for biographies of historically excluded STEMM professionals,
+# based on Wikidata queries and Wikipedia categories we compiled here:
+# https://en.wikipedia.org/wiki/Wikipedia:Wiki_Education/Biographies
+
+# run it like:
+# bundle exec rails r docs/analytics_scripts/stemm_bios.rb
+
+require_relative "../../lib/importers/category_importer"
+
+en_wiki = Wiki.find_by(language: 'en', project: 'wikipedia')
+
+AA_BIOS_CAT = 'Category:21st-century African-American scientists'
+aa_bios = CategoryImporter.new(en_wiki).mainspace_page_titles_for_category(AA_BIOS_CAT, 1) # depth 1
+
+
+HLA_CAT = 'Category:Hispanic and Latino American scientists'
+hla_bios = CategoryImporter.new(en_wiki).mainspace_page_titles_for_category(HLA_CAT, 4) # depth 4
+
+# wikidata # 1
+# wikidata # 2
+
+def mainspace_links(page_title)
+ query = {
+ prop: 'links',
+ plnamespace: 0,
+ pllimit: 500,
+ titles: page_title
+ }
+ links = []
+ cont = true
+ resp = WikiApi.new.query(query)
+
+ until cont.nil? do
+ page_id = resp.data['pages'].keys.first
+ resp_links = resp.data.dig('pages', page_id, 'links')
+ links << resp_links
+ cont = resp['continue']
+
+ resp = WikiApi.new.query(query.merge cont) if cont
+ end
+
+ links.flatten.map { |t| t['title'] }.uniq
+end
+
+american_women_stem_bios = mainspace_links 'Wikipedia:Wiki Education/Biographies/American women in STEM'
+american_women_researcher_bios = mainspace_links 'Wikipedia:Wiki_Education/Biographies/American_women_researchers'
+
+all_bios = aa_bios + hla_bios + american_women_stem_bios + american_women_researcher_bios
+
+all_bios.uniq!
+
+edited_articles = CSV.read('/home/sage/Downloads/spring_2024-wikiarticles.csv') + CSV.read('/home/sage/Downloads/fall_2024-wikiarticles.csv')
+edited_articles.flatten!.map! { |t| t.gsub('_', ' ') }
+
+edited_bios = edited_articles & all_bios
+
+puts edited_bios.uniq.count
+
+broadcom = CSV.read('/home/sage/Downloads/2025_broadcom.csv')
+
+# This doesn't actually get uniqueness right, possibly because of line endings or something, so I used a spreadsheet
+# to do final de-duplication.
+puts (broadcom + edited_bios).uniq.count
+puts (broadcom + edited_bios).sort
\ No newline at end of file
diff --git a/docs/docker.md b/docs/docker.md
index 1721bcb48c..67ffabc9fa 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -58,3 +58,10 @@ If you are using Linux you may encounter permission error when trying to change
$ sudo chown $USER:$GROUPS -R ./
```
The command will change the owner of all file inside project directory to the `$USER:$GROUPS`
+
+If you encounter the following error when connecting to the database: _"There is an issue connecting with your hostname mysql"_, please run the following command:
+```sh
+bash ./update_hosts.sh
+```
+This script automatically resolves the issue by ensuring proper network mapping in your system's ```/etc/hosts``` file, facilitating seamless database connections. It retrieves the IP address of the MySQL Docker container, checks for any existing hostname entries, and updates or removes outdated ones. This process ensures that the hostname ```mysql``` is correctly mapped to the container’s IP address.
+This bash script is designed for Linux distributions like Fedora.
diff --git a/docs/frontend.md b/docs/frontend.md
index 257cf7bfd8..c062b9957a 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -8,6 +8,9 @@ The stylesheet language used is Stylus along with its Rupture utility.
### Resources
- [Thinking in React](https://facebook.github.io/react/docs/thinking-in-react.html)
- [Redux docs](http://redux.js.org/)
+- [Redux Toolkit docs - The official, opinionated toolkit for Redux development](https://redux-toolkit.js.org/)
+- [Immer docs - Library for working with immutable state](https://immerjs.github.io/immer/)
+- [Immutability in React and Redux: The Complete Guide](https://daveceddia.com/react-redux-immutability-guide/)
- [Getting Started with Redux](https://egghead.io/courses/getting-started-with-redux)
- [React DnD](http://gaearon.github.io/react-dnd/) - The library used for drag-and-drop interactions on the timeline
- [Stylus](https://github.com/stylus/stylus/)
@@ -21,6 +24,10 @@ New features should rely on actions in the form of plain objects that are dispat
### Redux store
Redux uses a single store, which is built up from many independent reducer functions that handle different actions. Container components — those that determine which child components to render based on application state — can then use `connect()` to subscribe to the store, typically with `mapStateToProps()` and `mapDispatchToProps()` functions used to filter out just the data and actions that the Component needs. Using this standard Redux pattern, we pass only the props needed by the immediate child components. This minimizes the amount of rendering, since a React component re-renders whenever its props change.
+### Immer Usage
+For components still using traditional Redux, you can use Immer's [produce](https://immerjs.github.io/immer/produce) function directly to simplify immutable updates.
+
+
### Components
Components are the view layer of the JS application. They contain HTML markup and methods that trigger Actions based on user input.
diff --git a/docs/setup.md b/docs/setup.md
index 5d10527a56..eed8430d67 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -24,7 +24,7 @@ For Windows, the easiest way to get an environment set up is to [use Ubuntu with
## Prerequisite
There are some basic requirements for the script to work:
- git (to clone the repository)
-- node version 14 or 16
+- node version 14 or newer
- python 3.5 or newer
- ruby 3.1.2
- apt (debian) or homebrew (MacOS)
diff --git a/docs/training_module_scripts/training_content_to_docx.rb b/docs/training_module_scripts/training_content_to_docx.rb
new file mode 100644
index 0000000000..532391cf5f
--- /dev/null
+++ b/docs/training_module_scripts/training_content_to_docx.rb
@@ -0,0 +1,27 @@
+
+ # Script to create a Google Doc for reviewing and preparing edits for training modules.
+
+ def html_from_slide(slide)
+ "\n #{slide.title}" <<
+ PandocRuby.convert(slide.content, from: :markdown_github, to: :html) <<
+ "
"
+ end
+
+ output = ''
+
+TrainingModule.all.each do |tm|
+ output += "--- Training Module ##{tm.id}: #{tm.name} ---"
+ output += "\n #{tm.description} "
+ tm.slides.each do |slide|
+ output += html_from_slide(slide)
+ end
+end
+
+File.open('training_content.html', 'wb') { |file| file.write(output) }
+
+# The pandoc conversion fails if a local asset file from the html does not exist.
+# As of January 2025, the only case of this is the inline icon in slide 334-authorship-highlighting.yml
+# Edit the html file to turn it into an absolute path — https://dashboard.wikiedu.org/assets/images/article-viewer.svg
+# then proceed with the conversion.
+
+`pandoc training_content.html --to docx --output trainings.docx`
\ No newline at end of file
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 05b3201769..e333688461 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -43,7 +43,7 @@ Your system has cmdtest installed, which provides a different program as yarn. U
- **To check if redis is running as a daemon in Linux** `ps aux | grep redis-server`
-- Use node v10 or lower to avoid any errors.
+- Use node v14 or newer to avoid any errors.
- **For WSL users , if rspec tests are taking too long to run** make sure to fork the repo in the linux file system and not in the windows partition. Use command `pwd` to know the exact path of your repo. If the path starts with `/mnt/`, the repo is in windows partition. Follow the documentation available at link: https://learn.microsoft.com/en-us/windows/wsl/filesystems to know more about storing and moving files in the linux file system.
If you have received error related to dependencies(`sudo apt-get install -y redis-server mariadb-server libmariadb-dev rvm nodejs npm pandoc`), try to install packages one by one and figure out which packages are creating problems.
diff --git a/fixtures/vcr_cassettes/cached/assigned_articles_view.yml b/fixtures/vcr_cassettes/cached/assigned_articles_view.yml
new file mode 100644
index 0000000000..c869051cfa
--- /dev/null
+++ b/fixtures/vcr_cassettes/cached/assigned_articles_view.yml
@@ -0,0 +1,133 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: https://en.wikipedia.org/w/api.php?action=query&format=json&prop=revisions&rvprop=ids&titles=Nancy%20Tuana
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ User-Agent:
+ - Faraday v1.10.2
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Tue, 28 Jan 2025 18:55:00 GMT
+ Server:
+ - mw-api-ext.eqiad.main-5f7d7bfc55-psxcn
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - DENY
+ Content-Disposition:
+ - inline; filename=api-result.json
+ Vary:
+ - Accept-Encoding,Treat-as-Untrusted,X-Forwarded-Proto,Cookie,Authorization
+ Cache-Control:
+ - private, must-revalidate, max-age=0
+ Content-Type:
+ - application/json; charset=utf-8
+ Age:
+ - '0'
+ X-Cache:
+ - cp6014 miss, cp6011 pass
+ X-Cache-Status:
+ - pass
+ Server-Timing:
+ - cache;desc="pass", host;desc="cp6011"
+ Strict-Transport-Security:
+ - max-age=106384710; includeSubDomains; preload
+ Report-To:
+ - '{ "group": "wm_nel", "max_age": 604800, "endpoints": [{ "url": "https://intake-logging.wikimedia.org/v1/events?stream=w3c.reportingapi.network_error&schema_uri=/w3c/reportingapi/network_error/1.0.0"
+ }] }'
+ Nel:
+ - '{ "report_to": "wm_nel", "max_age": 604800, "failure_fraction": 0.05, "success_fraction":
+ 0.0}'
+ Set-Cookie:
+ - GeoIP=CM:::6.00:12.50:v4; Path=/; secure; Domain=.wikipedia.org
+ - NetworkProbeLimit=0.001;Path=/;Secure;SameSite=Lax;Max-Age=3600
+ - WMF-Last-Access-Global=28-Jan-2025;Path=/;Domain=.wikipedia.org;HttpOnly;secure;Expires=Sat,
+ 01 Mar 2025 12:00:00 GMT
+ - WMF-Last-Access=28-Jan-2025;Path=/;HttpOnly;secure;Expires=Sat, 01 Mar 2025
+ 12:00:00 GMT
+ X-Client-Ip:
+ - 102.244.157.204
+ Accept-Ranges:
+ - bytes
+ Content-Length:
+ - '144'
+ body:
+ encoding: ASCII-8BIT
+ string: '{"batchcomplete":"","query":{"pages":{"51910526":{"pageid":51910526,"ns":0,"title":"Nancy
+ Tuana","revisions":[{"revid":1220423548,"parentid":1180607625}]}}}}'
+ recorded_at: Tue, 28 Jan 2025 18:55:00 GMT
+- request:
+ method: post
+ uri: https://api.wikimedia.org/service/lw/inference/v1/models/enwiki-articlequality:predict
+ body:
+ encoding: UTF-8
+ string: '{"rev_id":1220423548,"extended_output":true}'
+ headers:
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Faraday v1.10.2
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 28 Jan 2025 18:55:00 GMT
+ Server:
+ - envoy
+ Cache-Control:
+ - no-cache
+ X-Ratelimit-Limit:
+ - 50000, 50000;w=3600
+ X-Ratelimit-Remaining:
+ - '49994'
+ X-Ratelimit-Reset:
+ - '299'
+ Vary:
+ - Accept-Encoding
+ Age:
+ - '1'
+ X-Cache:
+ - cp6014 miss, cp6011 pass
+ X-Cache-Status:
+ - pass
+ Server-Timing:
+ - cache;desc="pass", host;desc="cp6011"
+ Strict-Transport-Security:
+ - max-age=106384710; includeSubDomains; preload
+ Report-To:
+ - '{ "group": "wm_nel", "max_age": 604800, "endpoints": [{ "url": "https://intake-logging.wikimedia.org/v1/events?stream=w3c.reportingapi.network_error&schema_uri=/w3c/reportingapi/network_error/1.0.0"
+ }] }'
+ Nel:
+ - '{ "report_to": "wm_nel", "max_age": 604800, "failure_fraction": 0.05, "success_fraction":
+ 0.0}'
+ X-Client-Ip:
+ - 102.244.157.204
+ Accept-Ranges:
+ - bytes
+ Transfer-Encoding:
+ - chunked
+ body:
+ encoding: ASCII-8BIT
+ string: '{"enwiki":{"models":{"articlequality":{"version":"0.9.2"}},"scores":{"1220423548":{"articlequality":{"score":{"prediction":"Start","probability":{"B":0.0967005597473408,"C":0.33396017760551916,"FA":0.00454057782408097,"GA":0.012108337779692136,"Start":0.5182220232989376,"Stub":0.03446832374442916}},"features":{"feature.wikitext.revision.chars":6782.0,"feature.wikitext.revision.content_chars":3161.0,"feature.wikitext.revision.ref_tags":5.0,"feature.wikitext.revision.wikilinks":17.0,"feature.wikitext.revision.external_links":12.0,"feature.wikitext.revision.headings_by_level(2)":3.0,"feature.wikitext.revision.headings_by_level(3)":0.0,"feature.wikitext.revision.list_items":8.0,"feature.enwiki.revision.image_links":0.0,"feature.enwiki.revision.image_template":0.0,"feature.enwiki.revision.images_in_templates":0,"feature.enwiki.revision.images_in_tags":0,"feature.enwiki.infobox_images":0,"feature.enwiki.revision.category_links":8.0,"feature.enwiki.revision.shortened_footnote_templates":0.0,"feature.enwiki.revision.cite_templates":12.0,"feature.wikitext.revision.templates":19.0,"feature.enwiki.revision.infobox_templates":1.0,"feature.enwiki.revision.cn_templates":0.0,"feature.enwiki.revision.who_templates":0.0,"feature.enwiki.main_article_templates":0.0,"feature.english.stemmed.revision.stems_length":3928,"feature.enwiki.revision.paragraphs_without_refs_total_length":3674.0,"feature.len( )":1.0,"feature.len()":782.0,"feature.len()":0.0}}}}}}'
+ recorded_at: Tue, 28 Jan 2025 18:55:01 GMT
+recorded_with: VCR 6.1.0
diff --git a/lib/alerts/articles_for_deletion_monitor.rb b/lib/alerts/articles_for_deletion_monitor.rb
index 53a3c8b27b..e85e3ffe44 100644
--- a/lib/alerts/articles_for_deletion_monitor.rb
+++ b/lib/alerts/articles_for_deletion_monitor.rb
@@ -104,13 +104,10 @@ def normalize_titles
def create_alert(articles_course)
return if alert_already_exists?(articles_course)
- first_revision = articles_course
- .course.revisions.where(article_id: articles_course.article_id).first
alert = Alert.create!(type: 'ArticlesForDeletionAlert',
article_id: articles_course.article_id,
- user_id: first_revision&.user_id,
- course_id: articles_course.course_id,
- revision_id: first_revision&.id)
+ user_id: articles_course&.user_ids&.first,
+ course_id: articles_course.course_id)
alert.email_content_expert
end
diff --git a/lib/alerts/continued_course_activity_alert_manager.rb b/lib/alerts/continued_course_activity_alert_manager.rb
index 2a8e501f88..df868b4480 100644
--- a/lib/alerts/continued_course_activity_alert_manager.rb
+++ b/lib/alerts/continued_course_activity_alert_manager.rb
@@ -22,7 +22,7 @@ def create_alerts
private
- MINIMUM_CHARACTERS_ADDED_AFTER_COURSE_END = 1000
+ MINIMUM_CHARACTERS_ADDED_AFTER_COURSE_END = 200
def significant_activity_after_course_end?(course)
user_ids = course.students.pluck(:id)
post_course_characters = Revision
diff --git a/lib/alerts/dyk_nomination_monitor.rb b/lib/alerts/d_y_k_nomination_monitor.rb
similarity index 87%
rename from lib/alerts/dyk_nomination_monitor.rb
rename to lib/alerts/d_y_k_nomination_monitor.rb
index 3f8177b645..f905a649d4 100644
--- a/lib/alerts/dyk_nomination_monitor.rb
+++ b/lib/alerts/d_y_k_nomination_monitor.rb
@@ -54,13 +54,10 @@ def normalize_titles
def create_alert(articles_course)
return if alert_already_exists?(articles_course)
- first_revision = articles_course
- .course.revisions.where(article_id: articles_course.article_id).first
alert = Alert.create!(type: 'DYKNominationAlert',
article_id: articles_course.article_id,
- user_id: first_revision&.user_id,
- course_id: articles_course.course_id,
- revision_id: first_revision&.id)
+ user_id: articles_course&.user_ids&.first,
+ course_id: articles_course.course_id)
alert.email_instructors_and_wikipedia_experts
end
diff --git a/lib/alerts/discretionary_sanctions_monitor.rb b/lib/alerts/discretionary_sanctions_monitor.rb
index 1fb419261d..c5c09f3121 100644
--- a/lib/alerts/discretionary_sanctions_monitor.rb
+++ b/lib/alerts/discretionary_sanctions_monitor.rb
@@ -57,15 +57,11 @@ def normalize_titles
def create_edit_alert(articles_course)
return if unresolved_edit_alert_already_exists?(articles_course)
- revisions = articles_course.course.revisions.where(article_id: articles_course.article_id)
- last_revision = revisions.last
- return if resolved_edit_alert_covers_latest_revision?(articles_course, last_revision)
- first_revision = revisions.first
+ return if resolved_edit_alert_covers_latest_revision?(articles_course)
alert = Alert.create!(type: 'DiscretionarySanctionsEditAlert',
article_id: articles_course.article_id,
- user_id: first_revision&.user_id,
- course_id: articles_course.course_id,
- revision_id: first_revision&.id)
+ user_id: articles_course&.user_ids&.first,
+ course_id: articles_course.course_id)
alert.email_content_expert
end
@@ -90,12 +86,58 @@ def unresolved_assignment_alert_already_exists?(assignments_course)
resolved: false)
end
- def resolved_edit_alert_covers_latest_revision?(articles_course, last_revision)
- return false if last_revision.nil?
+ def resolved_edit_alert_covers_latest_revision?(articles_course)
last_resolved = DiscretionarySanctionsEditAlert.where(article_id: articles_course.article_id,
course_id: articles_course.course_id,
resolved: true).last
return false unless last_resolved.present?
- last_resolved.created_at > last_revision.date
+
+ course = Course.find(articles_course.course_id)
+ # If the last resolved alert was created after the course end, do not create a new one
+ return true if last_resolved.created_at > course.end
+
+ mw_page_id = articles_course.article.mw_page_id
+ return !course_edit_after?(course, mw_page_id, last_resolved.created_at)
+ end
+
+ # Returns true if there was an edit made by a course student to the specified page
+ # within the period from the creation date of the last resolved alert to the course end date.
+ def course_edit_after?(course, page_id, last_resolved_date)
+ @api = WikiApi.new @wiki
+ @query_params = query_params(course, page_id, last_resolved_date)
+ @continue = true
+ until @continue.nil?
+ response = @api.query(@query_params)
+ return false unless response
+ reivisons = filter_revisions(response, page_id, course)
+ # If we found an edit made by the user then return true
+ return true if reivisons.present?
+ @continue = response['continue']
+ @query_params['rvcontinue'] = @continue['rvcontinue'] if @continue
+ end
+ false
+ end
+
+ # Filters the API response to exclude edits made by users who are not course students.
+ # Returns only the edits associated with the course.
+ def filter_revisions(response, page_id, course)
+ revisions = response.data['pages'][page_id.to_s]['revisions']
+ return if revisions.nil?
+ students = course.students.pluck(:username)
+ revisions.select { |revision| students.include?(revision['user']) }
+ end
+
+ # Queries for edits made to the specified page within the period
+ # [last resolved alert, course end]
+ def query_params(course, page_id, last_resolved_date)
+ {
+ action: 'query',
+ prop: 'revisions',
+ pageids: page_id,
+ rvend: last_resolved_date.strftime('%Y%m%d%H%M%S'),
+ rvstart: course.end.strftime('%Y%m%d%H%M%S'),
+ rvdir: 'older', # List newest first. rvstart has to be later than rvend.
+ rvlimit: 500
+ }
end
end
diff --git a/lib/alerts/ga_nomination_monitor.rb b/lib/alerts/g_a_nomination_monitor.rb
similarity index 85%
rename from lib/alerts/ga_nomination_monitor.rb
rename to lib/alerts/g_a_nomination_monitor.rb
index 6e3cd7b194..aa9d2f9001 100644
--- a/lib/alerts/ga_nomination_monitor.rb
+++ b/lib/alerts/g_a_nomination_monitor.rb
@@ -50,13 +50,10 @@ def normalize_titles
def create_alert(articles_course)
return if alert_already_exists?(articles_course)
- first_revision = articles_course
- .course.revisions.where(article_id: articles_course.article_id).first
alert = Alert.create!(type: 'GANominationAlert',
article_id: articles_course.article_id,
- user_id: first_revision&.user_id,
- course_id: articles_course.course_id,
- revision_id: first_revision&.id)
+ user_id: articles_course&.user_ids&.first,
+ course_id: articles_course.course_id)
alert.email_content_expert
end
diff --git a/lib/alerts/high_quality_article_monitor.rb b/lib/alerts/high_quality_article_monitor.rb
index 4a7abeb7b2..9480aabcc5 100644
--- a/lib/alerts/high_quality_article_monitor.rb
+++ b/lib/alerts/high_quality_article_monitor.rb
@@ -51,15 +51,11 @@ def normalize_titles
def create_edit_alert(articles_course)
return if unresolved_edit_alert_already_exists?(articles_course)
- revisions = articles_course.course.revisions.where(article_id: articles_course.article_id)
- last_revision = revisions.last
- return if resolved_alert_covers_latest_revision?(articles_course, last_revision)
- first_revision = revisions.first
+ return if resolved_alert_covers_latest_revision?(articles_course)
alert = Alert.create!(type: 'HighQualityArticleEditAlert',
article_id: articles_course.article_id,
- user_id: first_revision&.user_id,
- course_id: articles_course.course_id,
- revision_id: first_revision&.id)
+ user_id: articles_course&.user_ids&.first,
+ course_id: articles_course.course_id)
alert.email_content_expert
end
@@ -84,12 +80,58 @@ def unresolved_assignment_alert_already_exists?(assignments_course)
resolved: false)
end
- def resolved_alert_covers_latest_revision?(articles_course, last_revision)
- return false if last_revision.nil?
+ def resolved_alert_covers_latest_revision?(articles_course)
last_resolved = HighQualityArticleEditAlert.where(article_id: articles_course.article_id,
course_id: articles_course.course_id,
resolved: true).last
return false unless last_resolved.present?
- last_resolved.created_at > last_revision.date
+
+ course = Course.find(articles_course.course_id)
+ # If the last resolved alert was created after the course end, do not create a new one
+ return true if last_resolved.created_at > course.end
+
+ mw_page_id = articles_course.article.mw_page_id
+ return !course_edit_after?(course, mw_page_id, last_resolved.created_at)
+ end
+
+ # Returns true if there was an edit made by a course student to the specified page
+ # within the period from the creation date of the last resolved alert to the course end date.
+ def course_edit_after?(course, page_id, last_resolved_date)
+ @api = WikiApi.new @wiki
+ @query_params = query_params(course, page_id, last_resolved_date)
+ @continue = true
+ until @continue.nil?
+ response = @api.query(@query_params)
+ return false unless response
+ reivisons = filter_revisions(response, page_id, course)
+ # If we found an edit made by the user then return true
+ return true if reivisons.present?
+ @continue = response['continue']
+ @query_params['rvcontinue'] = @continue['rvcontinue'] if @continue
+ end
+ false
+ end
+
+ # Filters the API response to exclude edits made by users who are not course students.
+ # Returns only the edits associated with the course.
+ def filter_revisions(response, page_id, course)
+ revisions = response.data['pages'][page_id.to_s]['revisions']
+ return if revisions.nil?
+ students = course.students.pluck(:username)
+ revisions.select { |revision| students.include?(revision['user']) }
+ end
+
+ # Queries for edits made to the specified page within the period
+ # [last resolved alert, course end]
+ def query_params(course, page_id, last_resolved_date)
+ {
+ action: 'query',
+ prop: 'revisions',
+ pageids: page_id,
+ rvend: last_resolved_date.strftime('%Y%m%d%H%M%S'),
+ rvstart: course.end.strftime('%Y%m%d%H%M%S'),
+ rvdir: 'older', # List newest first. rvstart has to be later than rvend.
+ rvlimit: 500
+ }
end
end
diff --git a/lib/assignment_manager.rb b/lib/assignment_manager.rb
index 313cec6422..d1bd7af70f 100644
--- a/lib/assignment_manager.rb
+++ b/lib/assignment_manager.rb
@@ -107,10 +107,12 @@ def set_article_from_database
def check_wiki_edu_discouraged_article
category = Category.find_by(name: ENV['blocked_assignment_category'])
+ article_discouraged = (category.present? && category.article_titles.include?(@clean_title))
+ handle_discouraged_article if article_discouraged
+ end
- if category.present? && category.article_titles.include?(@clean_title)
- raise DiscouragedArticleError, I18n.t('assignments.blocked_assignment', title: @clean_title)
- end
+ def handle_discouraged_article
+ raise DiscouragedArticleError, I18n.t('assignments.blocked_assignment', title: @clean_title)
end
def import_article_from_wiki
diff --git a/lib/data_cycle/update_cycle_alert_generator.rb b/lib/data_cycle/update_cycle_alert_generator.rb
index 33e5722eee..0212f190dd 100644
--- a/lib/data_cycle/update_cycle_alert_generator.rb
+++ b/lib/data_cycle/update_cycle_alert_generator.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
require_dependency "#{Rails.root}/lib/alerts/articles_for_deletion_monitor"
-require_dependency "#{Rails.root}/lib/alerts/dyk_nomination_monitor"
-require_dependency "#{Rails.root}/lib/alerts/ga_nomination_monitor"
+require_dependency "#{Rails.root}/lib/alerts/d_y_k_nomination_monitor"
+require_dependency "#{Rails.root}/lib/alerts/g_a_nomination_monitor"
require_dependency "#{Rails.root}/lib/alerts/course_alert_manager"
require_dependency "#{Rails.root}/lib/alerts/survey_response_alert_manager"
require_dependency "#{Rails.root}/lib/alerts/discretionary_sanctions_monitor"
diff --git a/lib/importers/user_importer.rb b/lib/importers/user_importer.rb
index c1feebbc5a..cbeab717e8 100644
--- a/lib/importers/user_importer.rb
+++ b/lib/importers/user_importer.rb
@@ -114,7 +114,7 @@ def self.get_global_id(username)
def self.update_user_from_wiki(user, wiki)
user_data = WikiApi.new(wiki).get_user_info(user.username)
- return if user_data['missing']
+ return if user_data.nil? || user_data['missing']
user.update!(username: user_data['name'],
registered_at: user_data['registration'],
global_id: user_data&.dig('centralids', 'CentralAuth'))
diff --git a/lib/lift_wing_api.rb b/lib/lift_wing_api.rb
index 7e3b949b2b..47ce0ccd53 100644
--- a/lib/lift_wing_api.rb
+++ b/lib/lift_wing_api.rb
@@ -20,6 +20,8 @@ class LiftWingApi
# All the wikis with an articlequality model as of 2023-06-28
# https://wikitech.wikimedia.org/wiki/Machine_Learning/LiftWingq
AVAILABLE_WIKIPEDIAS = %w[en eu fa fr gl nl pt ru sv tr uk].freeze
+ # config/initializers/retry_config.rb
+ RETRY_COUNT = 5
def self.valid_wiki?(wiki)
return true if wiki.project == 'wikidata'
@@ -51,7 +53,6 @@ def get_revision_data(rev_ids)
end
log_error_batch(rev_ids)
-
return results
end
@@ -60,7 +61,7 @@ def get_revision_data(rev_ids)
# Returns a hash with wp10, features, deleted, and prediction, or empty hash if
# there is an error.
def get_single_revision_parsed_data(rev_id)
- tries ||= 5
+ tries ||= RETRY_COUNT
body = { rev_id:, extended_output: true }.to_json
response = lift_wing_server.post(quality_query_url, body)
parsed_response = Oj.load(response.body)
@@ -69,7 +70,6 @@ def get_single_revision_parsed_data(rev_id)
return { 'wp10' => nil, 'features' => nil, 'deleted' => deleted?(parsed_response),
'prediction' => nil }
end
-
build_successful_response(rev_id, parsed_response)
rescue StandardError => e
tries -= 1
diff --git a/lib/list_course_manager.rb b/lib/list_course_manager.rb
index e541424eac..0208b9de11 100644
--- a/lib/list_course_manager.rb
+++ b/lib/list_course_manager.rb
@@ -39,12 +39,11 @@ def add_instructor_real_names
def add_classroom_program_manager_if_exists
cpm = SpecialUsers.classroom_program_manager
- if cpm && @course.type == 'ClassroomProgramCourse'
- CoursesUsers.create(user: cpm,
- course: @course,
- role: CoursesUsers::Roles::WIKI_ED_STAFF_ROLE,
- real_name: cpm.real_name)
- end
+ return unless cpm && @course.type == 'ClassroomProgramCourse'
+ CoursesUsers.create(user: cpm,
+ course: @course,
+ role: CoursesUsers::Roles::WIKI_ED_STAFF_ROLE,
+ real_name: cpm.real_name)
end
def send_approval_notification_emails
diff --git a/lib/personal_data/personal_data_csv_builder.rb b/lib/personal_data/personal_data_csv_builder.rb
new file mode 100644
index 0000000000..a5e27256d7
--- /dev/null
+++ b/lib/personal_data/personal_data_csv_builder.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+module PersonalData
+ class PersonalDataCsvBuilder
+ def initialize(user)
+ @user = user
+ end
+
+ def generate_csv
+ CSV.generate(headers: true) do |csv|
+ add_user_info(csv)
+ add_user_profile_info(csv)
+ add_course_info(csv)
+ add_campaign_info(csv)
+ end
+ end
+
+ private
+
+ def add_user_info(csv)
+ csv << ['Username', 'Real Name', 'Email', 'Created At', 'Updated At', 'Locale', 'First Login']
+ csv << [
+ @user.username, @user.real_name, @user.email, @user.created_at,
+ @user.updated_at, @user.locale, @user.first_login
+ ]
+ end
+
+ def add_user_profile_info(csv)
+ return unless @user.user_profile
+
+ csv << [
+ 'Bio', 'Image File Name', 'Image Link', 'Image Updated At',
+ 'Location', 'Institution', 'Email Preferences'
+ ]
+ csv << [
+ @user.user_profile.bio, @user.user_profile.image_file_name,
+ @user.user_profile.image_file_link, @user.user_profile.image_updated_at,
+ @user.user_profile.location, @user.user_profile.institution,
+ @user.user_profile.email_preferences
+ ]
+ end
+
+ def add_course_info(csv)
+ @user.courses_users.includes(:course).each do |course_user|
+ csv << [
+ 'Course', 'Role', 'Real Name', 'Role Description',
+ 'Character Sum MS', 'Character Sum US', 'Character Sum Draft',
+ 'Revision Count', 'References Count', 'Recent Revisions', 'Enrolled At'
+ ]
+ csv << [
+ course_user.course.slug, course_user.role, course_user.real_name,
+ course_user.role_description, course_user.character_sum_ms,
+ course_user.character_sum_us, course_user.character_sum_draft,
+ course_user.revision_count, course_user.references_count,
+ course_user.recent_revisions, course_user.created_at
+ ]
+
+ add_assignments_info(csv, course_user)
+ end
+ end
+
+ def add_assignments_info(csv, course_user)
+ course_user.assignments.each do |assignment|
+ csv << ['Assignment Title', 'Assignment URL', 'Role', 'Created At', 'Sandbox URL']
+ csv << [
+ assignment.article_title, assignment.article_url,
+ assignment.role, assignment.created_at, assignment.sandbox_url
+ ]
+ end
+ end
+
+ def add_campaign_info(csv)
+ @user.campaigns_users.includes(:campaign).each do |campaign_user|
+ csv << ['Campaign', 'Joined At']
+ csv << [campaign_user.campaign.slug, campaign_user.created_at]
+ end
+ end
+ end
+end
diff --git a/lib/petscan_api.rb b/lib/petscan_api.rb
index 3dcddfb12d..cfe680e562 100644
--- a/lib/petscan_api.rb
+++ b/lib/petscan_api.rb
@@ -8,7 +8,6 @@ class PetScanApi
def get_data(psid, update_service: nil)
url = query_url(psid)
response = petscan.get url
- puts response.body
Oj.load(response.body)
rescue StandardError => e
log_error(e, update_service:,
diff --git a/lib/reference_counter_api.rb b/lib/reference_counter_api.rb
index cd51cb023b..b70d79e44d 100644
--- a/lib/reference_counter_api.rb
+++ b/lib/reference_counter_api.rb
@@ -56,15 +56,12 @@ def get_number_of_references_from_revision_id(rev_id)
tries ||= 5
response = toolforge_server.get(references_query_url(rev_id))
parsed_response = Oj.load(response.body)
- if response.status == 200
- return { 'num_ref' => parsed_response['num_ref'] }
- else
- # Log the error and return empty hash
- # Sentry.capture_message 'Non-200 response hitting references counter API', level: 'warning',
- # extra: { project_code: @project_code, language_code: @language_code, rev_id:,
- # status_code: response.status, content: parsed_response }
- return { 'num_ref' => nil }
- end
+ return { 'num_ref' => parsed_response['num_ref'] } if response.status == 200
+ # Log the error and return empty hash
+ # Sentry.capture_message 'Non-200 response hitting references counter API', level: 'warning',
+ # extra: { project_code: @project_code, language_code: @language_code, rev_id:,
+ # status_code: response.status, content: parsed_response }
+ return { 'num_ref' => nil }
rescue StandardError => e
tries -= 1
retry unless tries.zero?
diff --git a/lib/revision_feedback_service.rb b/lib/revision_feedback_service.rb
index 356b426ee1..fbba09f3a9 100644
--- a/lib/revision_feedback_service.rb
+++ b/lib/revision_feedback_service.rb
@@ -19,9 +19,8 @@ def feedback
def citation_feedback
ref_tags = @features['feature.wikitext.revision.ref_tags']
# cite_templates = @features['feature.enwiki.revision.cite_templates']
- if ref_tags < MINIMUM_REFERENCES
- @feedback << 'Cite your sources! This article needs more references.'
- end
+ return unless ref_tags < MINIMUM_REFERENCES
+ @feedback << 'Cite your sources! This article needs more references.'
end
# The largest reasonable average section size, calculated from content characters
diff --git a/lib/revision_score_api_handler.rb b/lib/revision_score_api_handler.rb
index 5cfc9c757f..8d9a8973d1 100644
--- a/lib/revision_score_api_handler.rb
+++ b/lib/revision_score_api_handler.rb
@@ -14,9 +14,8 @@ def initialize(language: 'en', project: 'wikipedia', wiki: nil, update_service:
# Initialize LiftWingApi if the wiki is valid for it
@lift_wing_api = LiftWingApi.new(@wiki, @update_service) if LiftWingApi.valid_wiki?(@wiki)
# Initialize ReferenceCounterApi if the wiki is valid for it
- if ReferenceCounterApi.valid_wiki?(@wiki)
- @reference_counter_api = ReferenceCounterApi.new(@wiki, @update_service)
- end
+ return unless ReferenceCounterApi.valid_wiki?(@wiki)
+ @reference_counter_api = ReferenceCounterApi.new(@wiki, @update_service)
end
# Returns data from LiftWing API and/or reference-counter API.
diff --git a/lib/training_module_due_date_manager.rb b/lib/training_module_due_date_manager.rb
index cba43334dc..178878a316 100644
--- a/lib/training_module_due_date_manager.rb
+++ b/lib/training_module_due_date_manager.rb
@@ -49,11 +49,8 @@ def module_progress
def flags(course_id)
flags_hash = @tmu&.flags
- if @training_module.exercise? && flags_hash.present?
- return flags_hash[course_id] || flags_hash
- else
- return flags_hash
- end
+ return flags_hash[course_id] || flags_hash if @training_module.exercise? && flags_hash.present?
+ return flags_hash
end
def sandbox_url
diff --git a/lib/wiki_api.rb b/lib/wiki_api.rb
index 65ddce0a7c..4f8b1f9fa0 100644
--- a/lib/wiki_api.rb
+++ b/lib/wiki_api.rb
@@ -33,6 +33,8 @@ def get_page_content(page_title)
response.body.force_encoding('UTF-8')
when 404
''
+ else
+ raise PageFetchError.new(page_title, response&.status)
end
end
@@ -47,7 +49,7 @@ def get_user_info(username)
ususers: username,
usprop: 'centralids|registration' }
user_data = mediawiki('query', user_query)
- return unless user_data.data['users'].any?
+ return unless user_data&.data&.dig('users')&.any?
user_data.data['users'][0]
end
@@ -121,6 +123,13 @@ def too_many_requests?(e)
e.status == 429
end
+ class PageFetchError < StandardError
+ def initialize(page, status)
+ message = "Failed to fetch content for #{page} with response status: #{status.inspect}"
+ super(message)
+ end
+ end
+
TYPICAL_ERRORS = [Faraday::TimeoutError,
Faraday::ConnectionFailed,
MediawikiApi::HttpError,
diff --git a/package.json b/package.json
index 8202d304dc..b53d61afbf 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"eslint-plugin-react": "^7.20.5",
"fetch-jsonp": "^1.2.1",
"i18n-js": "^4.2.2",
+ "immer": "^10.1.1",
"jeet": "^7.2.0",
"jest": "^26.0.1",
"jquery": "^3.5.1",
diff --git a/spec/controllers/system_status_controller_spec.rb b/spec/controllers/system_status_controller_spec.rb
new file mode 100644
index 0000000000..0c44b81636
--- /dev/null
+++ b/spec/controllers/system_status_controller_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe SystemStatusController, type: :request do
+ describe '#index' do
+ it 'sets @sidekiq_stats' do
+ get '/status'
+
+ expect(assigns(:sidekiq_stats)).to be_a(Hash)
+ expect(assigns(:sidekiq_stats)).to include(:enqueued_jobs, :active_jobs)
+ end
+
+ it 'sets @queue_metrics' do
+ get '/status'
+
+ queue_metrics = assigns(:queue_metrics)
+ expect(queue_metrics).to be_a(Hash)
+ expect(queue_metrics).to include(:queues, :paused_queues, :all_operational)
+
+ expect(queue_metrics[:queues]).to all(include(:name, :size, :status, :latency))
+ end
+
+ it 'renders the index template' do
+ get '/status'
+ expect(response).to render_template(:index)
+ end
+ end
+end
diff --git a/spec/controllers/users/enrollment_controller_spec.rb b/spec/controllers/users/enrollment_controller_spec.rb
index fa472f7c8a..9c7526a863 100644
--- a/spec/controllers/users/enrollment_controller_spec.rb
+++ b/spec/controllers/users/enrollment_controller_spec.rb
@@ -18,6 +18,7 @@
allow_any_instance_of(WikiCourseEdits).to receive(:update_course)
allow_any_instance_of(WikiCourseEdits).to receive(:remove_assignment)
allow_any_instance_of(WikiCourseEdits).to receive(:update_assignments)
+ allow_any_instance_of(WikiApi).to receive(:get_page_content).and_return('Some content')
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
course.campaigns << Campaign.first
end
diff --git a/spec/features/account_requests_spec.rb b/spec/features/account_requests_spec.rb
index b1ad0bb9ac..7f56875a5d 100644
--- a/spec/features/account_requests_spec.rb
+++ b/spec/features/account_requests_spec.rb
@@ -15,6 +15,7 @@
end
it 'can be enabled by the course facilitator' do
+ pending 'Confirmation modal is not displaying'
login_as(instructor)
visit "/courses/#{course.slug}"
click_button 'Enable account requests'
@@ -23,6 +24,7 @@
click_button 'OK'
end
expect(page).to have_content('Account request generation enabled')
+ pass_pending_spec
end
it 'can be used by logged out users via the enroll link' do
diff --git a/spec/features/assigned_articles_spec.rb b/spec/features/assigned_articles_spec.rb
index 9f2822673c..b9de285fd1 100644
--- a/spec/features/assigned_articles_spec.rb
+++ b/spec/features/assigned_articles_spec.rb
@@ -19,11 +19,10 @@
it 'lets users submit feedback about articles' do
# This makes a call to the LiftWing API from the server,
# we need to use VCR to avoid getting stopped by WebMock
- VCR.use_cassette('assigned_articles_view') do
+ VCR.use_cassette('cached/assigned_articles_view') do
visit "/courses/#{course.slug}/articles/assigned"
expect(page).to have_content('Nancy Tuana')
find('a', text: 'Feedback').click
- expect(page).to have_no_content(I18n.t('courses.feedback_loading'), wait: 10)
expect(page).to have_selector('textarea.feedback-form')
find('textarea.feedback-form').fill_in with: 'This is a great article!'
click_button 'Add Suggestion'
diff --git a/spec/features/multiwiki_assignment_spec.rb b/spec/features/multiwiki_assignment_spec.rb
index d119d009d4..ee1a6e99a5 100644
--- a/spec/features/multiwiki_assignment_spec.rb
+++ b/spec/features/multiwiki_assignment_spec.rb
@@ -58,7 +58,8 @@
within('#users') do
first('textarea').set(
- "Terre\nhttps://fr.wikipedia.org/wiki/Anglais"
+ "Terre\nhttps://fr.wikipedia.org/wiki/Anglais",
+ rapid: false
)
end
click_button 'Assign all'
@@ -86,7 +87,7 @@
button.click
within('#users') do
- find('textarea', visible: true).set('No le des prisa, dolor')
+ find('textarea', visible: true).set('No le des prisa, dolor', rapid: false)
click_link 'Change'
find('div.wiki-select').click
within('.wiki-select') do
@@ -116,7 +117,7 @@
expect(button).to have_content 'Assign/remove an article'
button.click
within('#users') do
- first('textarea').set('https://wikisource.org/wiki/Heyder_Cansa')
+ first('textarea').set('https://wikisource.org/wiki/Heyder_Cansa', rapid: false)
end
click_button 'Assign'
visit "/courses/#{course.slug}/students/articles"
@@ -139,7 +140,8 @@
expect(button).to have_content 'Assign/remove an article'
button.click
within('#users') do
- first('textarea').set('https://incubator.wikimedia.org/wiki/Wp/kiu/Heyder_Cansa')
+ first('textarea').set('https://incubator.wikimedia.org/wiki/Wp/kiu/Heyder_Cansa',
+ rapid: false)
end
click_button 'Assign'
visit "/courses/#{course.slug}/students/articles"
diff --git a/spec/features/student_role_spec.rb b/spec/features/student_role_spec.rb
index d6c124ae2c..361904049d 100644
--- a/spec/features/student_role_spec.rb
+++ b/spec/features/student_role_spec.rb
@@ -15,8 +15,8 @@
slug: 'University/An_Example_Course_(Term)',
submitted: true,
passcode: 'passcode',
- start: '2015-01-01'.to_date,
- end: '2025-01-01'.to_date)
+ start: '2025-01-01'.to_date,
+ end: 1.month.from_now)
end
let!(:editathon) do
create(:editathon,
@@ -26,8 +26,8 @@
slug: 'University/An_Example_Editathon_(Term)',
submitted: true,
passcode: '',
- start: '2015-01-01'.to_date,
- end: '2025-01-01'.to_date)
+ start: '2025-01-01'.to_date,
+ end: 1.month.from_now)
end
before do
diff --git a/spec/features/survey_bugs_spec.rb b/spec/features/survey_bugs_spec.rb
index e367f88cd7..9e0b02299a 100644
--- a/spec/features/survey_bugs_spec.rb
+++ b/spec/features/survey_bugs_spec.rb
@@ -128,14 +128,8 @@
click_button('Next', visible: true) # Q2
end
- # Now this question ideally should be skipped
- # but the code that did that breaks the survey
- # by removing the question without sliding
- # the next one into view.
- find('.label', text: 'Maybe').click
- within('div[data-progress-index="4"]') do
- click_button('Next', visible: true) # Q3
- end
+ sleep 2
+ expect(page).to have_content('Submit Survey')
# Now we can actually submit the survey
# and finish.
diff --git a/spec/features/training_tool_spec.rb b/spec/features/training_tool_spec.rb
index c51affa193..8b1d2c2b8f 100644
--- a/spec/features/training_tool_spec.rb
+++ b/spec/features/training_tool_spec.rb
@@ -2,11 +2,11 @@
require 'rails_helper'
-DESIRED_TRAINING_MODULES = [{ slug: 'evaluating-articles' }].freeze
+DESIRED_TRAINING_MODULES = [{ slug: 'sandboxes-talk-watchlists' }].freeze
describe 'Training', type: :feature, js: true do
let(:user) { create(:user, id: 1) }
- let(:module_2) { TrainingModule.find_by(slug: 'evaluating-articles') }
+ let(:module_2) { TrainingModule.find_by(slug: 'sandboxes-talk-watchlists') }
let(:new_instructor_orientation_module) do
TrainingModule.find_by(slug: 'new-instructor-orientation')
end
@@ -130,7 +130,7 @@
tmu = TrainingModulesUsers.find_by(user_id: user.id,
training_module_id: new_instructor_orientation_module.id)
visit "/training/students/#{new_instructor_orientation_module.slug}
- /#{new_instructor_orientation_module.slides.last.slug}"
+ /#{new_instructor_orientation_module.slides.last.slug}"
sleep 2
expect(tmu.reload.completed_at).to be_between(1.minute.ago, 1.minute.from_now)
expect(user.reload.permissions).to eq(User::Permissions::INSTRUCTOR)
@@ -209,6 +209,11 @@
end
describe 'finish module button' do
+ before do
+ TrainingSlide.load
+ visit "/training/students/#{module_2.slug}"
+ end
+
context 'logged in user' do
it 'redirects to their dashboard' do
login_as(user, scope: :user)
@@ -236,13 +241,13 @@
end
end
- DESIRED_TRAINING_MODULES.each do |module_slug|
- describe "'#{module_slug[:slug]}' module" do
- before do
- TrainingSlide.load
- end
+ describe 'lets the user go from start to finish' do
+ before do
+ TrainingSlide.load
+ end
- it 'lets the user go from start to finish' do
+ DESIRED_TRAINING_MODULES.each do |module_slug|
+ it "'#{module_slug[:slug]}' module" do
training_module = TrainingModule.find_by(module_slug)
go_through_module_from_start_to_finish(training_module)
end
@@ -279,7 +284,7 @@ def check_slide_contents(slide, slide_number, slide_count)
end
def proceed_to_next_slide
- if page.has_selector?('.alert-box-container', wait: 10)
+ if page.has_selector?('.alert-box-container')
within('.alert-box-container') do
find('.alert-button').click
end
diff --git a/spec/helpers/uploads_helper_spec.rb b/spec/helpers/uploads_helper_spec.rb
index 91ae000087..d03f4c2c99 100644
--- a/spec/helpers/uploads_helper_spec.rb
+++ b/spec/helpers/uploads_helper_spec.rb
@@ -10,5 +10,14 @@
result = pretty_filename(upload)
expect(result).to eq('My file.jpg')
end
+
+ it 'formats a complex filename with encoded characters nicely for display' do
+ upload = build(:commons_upload,
+ # rubocop:disable Layout/LineLength
+ file_name: 'File%3AA+sunflower+%F0%9F%8C%BB%F0%9F%8C%BB+in+Kaduna+Polytechnic%2CSabo+Campus.jpg')
+ # rubocop:enable Layout/LineLength
+ result = pretty_filename(upload)
+ expect(result).to eq('A sunflower 🌻🌻 in Kaduna Polytechnic,Sabo Campus.jpg')
+ end
end
end
diff --git a/spec/lib/alerts/articles_for_deletion_monitor_spec.rb b/spec/lib/alerts/articles_for_deletion_monitor_spec.rb
index db9ac47cae..62c59f0ce4 100644
--- a/spec/lib/alerts/articles_for_deletion_monitor_spec.rb
+++ b/spec/lib/alerts/articles_for_deletion_monitor_spec.rb
@@ -23,30 +23,20 @@ def mock_mailer
# AFD article
let(:article) { create(:article, title: 'One_page', namespace: 0) }
- let(:revision) do
- create(:revision, article_id: article.id,
- user_id: student.id,
- date: course.start + 1.day,
- new_article: article_is_new)
- end
let(:articles_course) do
create(:articles_course, article_id: article.id,
course_id: course.id,
- new_article: article_is_new)
+ new_article: article_is_new,
+ user_ids: [student.id])
end
# PRODded article
let(:prod) { create(:article, title: 'PRODded_page', namespace: 0) }
- let!(:prod_revision) do
- create(:revision, article_id: prod.id,
- user_id: student.id,
- date: course.start + 1.day,
- new_article: article_is_new)
- end
let!(:prod_articles_course) do
create(:articles_course, article_id: prod.id,
course_id: course.id,
- new_article: article_is_new)
+ new_article: article_is_new,
+ user_ids: [student.id])
end
before do
@@ -73,7 +63,7 @@ def mock_mailer
context 'when there is a new article' do
let(:article_is_new) { true }
- before { articles_course && revision && courses_user }
+ before { articles_course && courses_user }
it 'creates Alert records for both AfD and PROD' do
described_class.create_alerts_for_course_articles
@@ -83,6 +73,21 @@ def mock_mailer
expect(alerted_article_ids).to include(prod.id)
end
+ it 'assigns user_id from articles_course.user_ids.first' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert.user_id).to eq(student.id) # First user_id from user_ids
+ end
+
+ it 'creates Alert records without requiring revisions' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert).not_to be_nil
+ expect(alert.revision_id).to be_nil # revision_id should not matter
+ end
+
it 'emails a greeter' do
create(:courses_user, user_id: content_expert.id, course_id: course.id, role: 4)
allow_any_instance_of(AlertMailer).to receive(:alert).and_return(mock_mailer)
@@ -98,7 +103,7 @@ def mock_mailer
expect(Alert.count).to eq(2)
end
- it 'does create second Alert if the first alert is resolved' do
+ it 'does create a second Alert if the first alert is resolved' do
Alert.create(type: 'ArticlesForDeletionAlert', article_id: article.id,
course_id: course.id, resolved: true)
Alert.create(type: 'ArticlesForDeletionAlert', article_id: prod.id, course_id: course.id)
@@ -111,9 +116,9 @@ def mock_mailer
context 'when there is not a new article' do
let(:article_is_new) { false }
- before { articles_course && revision && courses_user }
+ before { articles_course && courses_user }
- it 'does still creates an Alert record' do
+ it 'still creates an Alert record' do
described_class.create_alerts_for_course_articles
expect(Alert.count).to eq(2)
end
diff --git a/spec/lib/alerts/discretionary_sanctions_monitor_spec.rb b/spec/lib/alerts/discretionary_sanctions_monitor_spec.rb
index 59e6e3ca60..ccc7bc2cbb 100644
--- a/spec/lib/alerts/discretionary_sanctions_monitor_spec.rb
+++ b/spec/lib/alerts/discretionary_sanctions_monitor_spec.rb
@@ -9,8 +9,8 @@ def mock_mailer
describe DiscretionarySanctionsMonitor do
describe '.create_alerts_for_course_articles' do
- let(:course) { create(:course, start: 1.month.ago, end: 1.month.after) }
- let(:student) { create(:user, username: 'student') }
+ let(:course) { create(:course, start: '2024-12-10', end: '2025-01-20') }
+ let(:student) { create(:user, username: 'Gelasin') }
let!(:courses_user) do
create(:courses_user, user_id: student.id,
course_id: course.id,
@@ -22,15 +22,13 @@ def mock_mailer
let!(:article2) { create(:article, title: '1948_war', namespace: 0) }
# Article that has been edited by a student
- let(:article) { create(:article, title: 'Ahmed_Mohamed_clock_incident', namespace: 0) }
- let!(:revision) do
- create(:revision, article_id: article.id,
- user_id: student.id,
- date: course.start + 1.day)
+ let(:article) do
+ create(:article, title: 'Ahmed_Mohamed_clock_incident', mw_page_id: 47905394, namespace: 0)
end
let!(:articles_course) do
create(:articles_course, article_id: article.id,
- course_id: course.id)
+ course_id: course.id,
+ user_ids: [student.id, 45])
end
let!(:assignment) do
@@ -54,6 +52,8 @@ def mock_mailer
expect(DiscretionarySanctionsAssignmentAlert.count).to eq(1)
alerted_edit_article_ids = DiscretionarySanctionsEditAlert.all.pluck(:article_id)
expect(alerted_edit_article_ids).to include(article.id)
+ alerted_edit_user_ids = DiscretionarySanctionsEditAlert.all.pluck(:user_id)
+ expect(alerted_edit_user_ids).to include(student.id)
alerted_assignment_article_ids = DiscretionarySanctionsAssignmentAlert.all.pluck(:article_id)
expect(alerted_assignment_article_ids).to include(article.id)
end
@@ -83,17 +83,31 @@ def mock_mailer
it 'does not create second Alert if the first alert is resolved but there are no new edits' do
Alert.create(type: 'DiscretionarySanctionsEditAlert', article_id: article.id,
- course_id: course.id, resolved: true, created_at: revision.date + 1.hour)
+ course_id: course.id, resolved: true, created_at: course.end - 1.minute)
expect(DiscretionarySanctionsEditAlert.count).to eq(1)
- described_class.create_alerts_for_course_articles
+ VCR.use_cassette 'discretionary_sanctions_monitors' do
+ described_class.create_alerts_for_course_articles
+ end
expect(DiscretionarySanctionsEditAlert.count).to eq(1)
end
- it 'does create second Alert if the first alert is resolved and there are later edits' do
+ it 'does not create second Alert if the first alert is resolved but no new student edits' do
Alert.create(type: 'DiscretionarySanctionsEditAlert', article_id: article.id,
- course_id: course.id, resolved: true, created_at: revision.date - 1.hour)
+ course_id: course.id, resolved: true, created_at: course.start + 1.day)
expect(DiscretionarySanctionsEditAlert.count).to eq(1)
- described_class.create_alerts_for_course_articles
+ VCR.use_cassette 'discretionary_sanctions_monitors' do
+ described_class.create_alerts_for_course_articles
+ end
+ expect(DiscretionarySanctionsEditAlert.count).to eq(1)
+ end
+
+ it 'does create second Alert if the first alert is resolved and later student edits' do
+ Alert.create(type: 'DiscretionarySanctionsEditAlert', article_id: article.id,
+ course_id: course.id, resolved: true, created_at: course.start + 1.minute)
+ expect(DiscretionarySanctionsEditAlert.count).to eq(1)
+ VCR.use_cassette 'discretionary_sanctions_monitors' do
+ described_class.create_alerts_for_course_articles
+ end
expect(DiscretionarySanctionsEditAlert.count).to eq(2)
end
diff --git a/spec/lib/alerts/dyk_nomination_monitor_spec.rb b/spec/lib/alerts/dyk_nomination_monitor_spec.rb
index 6793e6c4bb..409fecc605 100644
--- a/spec/lib/alerts/dyk_nomination_monitor_spec.rb
+++ b/spec/lib/alerts/dyk_nomination_monitor_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-require "#{Rails.root}/lib/alerts/dyk_nomination_monitor"
+require "#{Rails.root}/lib/alerts/d_y_k_nomination_monitor"
describe DYKNominationMonitor do
describe '.create_alerts_for_course_articles' do
@@ -20,14 +20,10 @@
# DYK article
let(:article) { create(:article, title: 'Venus_and_Adonis_(Titian)', namespace: 0) }
- let(:revision) do
- create(:revision, article_id: article.id,
- user_id: student.id,
- date: course.start + 1.day)
- end
let(:articles_course) do
create(:articles_course, article_id: article.id,
- course_id: course.id)
+ course_id: course.id,
+ user_ids: [student.id]) # Add user_ids for testing user_id logic
end
before do
@@ -37,16 +33,31 @@
'Template:Did you know nominations/2017–18 London & South East Premier',
'Template:Did you know nominations/17776'])
- articles_course && revision && courses_user
+ articles_course && courses_user # Ensure `ArticlesCourses` and `CoursesUser` are created
end
- it 'creates an Alert recordfor the edited article' do
+ it 'creates an Alert record for the edited article' do
described_class.create_alerts_for_course_articles
expect(Alert.count).to eq(1)
alerted_article_ids = Alert.all.pluck(:article_id)
expect(alerted_article_ids).to include(article.id)
end
+ it 'assigns user_id from articles_course.user_ids.first' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert.user_id).to eq(student.id) # First user_id from user_ids
+ end
+
+ it 'does not depend on revisions for creating alerts' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert).not_to be_nil
+ expect(alert.revision_id).to be_nil # revision_id is not required
+ end
+
it 'emails a greeter' do
create(:courses_user, user_id: content_expert.id, course_id: course.id, role: 4)
described_class.create_alerts_for_course_articles
@@ -67,7 +78,7 @@
expect(Alert.count).to eq(1)
end
- it 'does create second Alert if the first alert is resolved' do
+ it 'does create a second Alert if the first alert is resolved' do
Alert.create(type: 'DYKNominationAlert', article_id: article.id,
course_id: course.id, resolved: true)
expect(Alert.count).to eq(1)
diff --git a/spec/lib/alerts/ga_nomination_monitor_spec.rb b/spec/lib/alerts/ga_nomination_monitor_spec.rb
index 55bf13c71c..791b44b334 100644
--- a/spec/lib/alerts/ga_nomination_monitor_spec.rb
+++ b/spec/lib/alerts/ga_nomination_monitor_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-require "#{Rails.root}/lib/alerts/ga_nomination_monitor"
+require "#{Rails.root}/lib/alerts/g_a_nomination_monitor"
def mock_mailer
OpenStruct.new(deliver_now: true)
@@ -23,14 +23,10 @@ def mock_mailer
# Good Article article
let(:article) { create(:article, title: 'Be_Here_Now_(George_Harrison_song)', namespace: 0) }
- let(:revision) do
- create(:revision, article_id: article.id,
- user_id: student.id,
- date: course.start + 1.day)
- end
let(:articles_course) do
create(:articles_course, article_id: article.id,
- course_id: course.id)
+ course_id: course.id,
+ user_ids: [student.id]) # Add user_ids for testing user_id logic
end
before do
@@ -40,7 +36,7 @@ def mock_mailer
'Talk:2017–18 London & South East Premier',
'Talk:17776'])
- articles_course && revision && courses_user
+ articles_course && courses_user # Ensure `ArticlesCourses` and `CoursesUser` are created
end
it 'creates an Alert record for the edited article' do
@@ -50,6 +46,21 @@ def mock_mailer
expect(alerted_article_ids).to include(article.id)
end
+ it 'assigns user_id from articles_course.user_ids.first' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert.user_id).to eq(student.id) # First user_id from user_ids
+ end
+
+ it 'does not depend on revisions for creating alerts' do
+ described_class.create_alerts_for_course_articles
+ alert = Alert.find_by(article_id: article.id)
+
+ expect(alert).not_to be_nil
+ expect(alert.revision_id).to be_nil # revision_id is not required
+ end
+
it 'emails a content expert' do
create(:courses_user, user_id: content_expert.id, course_id: course.id, role: 4)
allow_any_instance_of(AlertMailer).to receive(:alert).and_return(mock_mailer)
@@ -64,7 +75,7 @@ def mock_mailer
expect(Alert.count).to eq(1)
end
- it 'does create second Alert if the first alert is resolved' do
+ it 'does create a second Alert if the first alert is resolved' do
Alert.create(type: 'GANominationAlert', article_id: article.id,
course_id: course.id, resolved: true)
expect(Alert.count).to eq(1)
diff --git a/spec/lib/alerts/high_quality_article_monitor_spec.rb b/spec/lib/alerts/high_quality_article_monitor_spec.rb
index 55c29375d0..83c7798af2 100644
--- a/spec/lib/alerts/high_quality_article_monitor_spec.rb
+++ b/spec/lib/alerts/high_quality_article_monitor_spec.rb
@@ -5,8 +5,8 @@
describe HighQualityArticleMonitor do
describe '.create_alerts_for_course_articles' do
- let(:course) { create(:course, start: 1.month.ago, end: 1.month.after) }
- let(:student) { create(:user, username: 'student', email: 'learn@school.edu') }
+ let(:course) { create(:course, start: '2024-01-01', end: '2025-01-01') }
+ let(:student) { create(:user, username: 'Leemyongpak', email: 'learn@school.edu') }
let(:instructor) { create(:user, username: 'instructor', email: 'teach@school.edu') }
let!(:courses_user) do
create(:courses_user, user_id: student.id,
@@ -19,15 +19,11 @@
let!(:article2) { create(:article, title: 'History_of_aspirin', namespace: 0) }
# Featured article edited by student
- let(:article) { create(:article, title: 'Phan_Đình_Phùng', namespace: 0) }
- let!(:revision) do
- create(:revision, article_id: article.id,
- user_id: student.id,
- date: course.start + 1.day)
- end
+ let(:article) { create(:article, title: 'Phan_Đình_Phùng', mw_page_id: 10771083, namespace: 0) }
let!(:articles_course) do
create(:articles_course, article_id: article.id,
- course_id: course.id)
+ course_id: course.id,
+ user_ids: [student.id, 45])
end
let!(:assignment) do
create(:assignment, article_title: article.title,
@@ -53,22 +49,20 @@
end
it 'creates Alert records for edited Good articles' do
- VCR.use_cassette 'high_quality' do
- described_class.create_alerts_for_course_articles
- end
+ described_class.create_alerts_for_course_articles
expect(HighQualityArticleEditAlert.count).to eq(1)
expect(HighQualityArticleAssignmentAlert.count).to eq(1)
alerted_edit_article_ids = HighQualityArticleEditAlert.all.pluck(:article_id)
expect(alerted_edit_article_ids).to include(article.id)
+ alerted_edit_user_ids = HighQualityArticleEditAlert.all.pluck(:user_id)
+ expect(alerted_edit_user_ids).to include(student.id)
alerted_assignment_article_ids = HighQualityArticleAssignmentAlert.all.pluck(:article_id)
expect(alerted_assignment_article_ids).to include(article.id)
end
it 'emails a greeter' do
create(:courses_user, user_id: content_expert.id, course_id: course.id, role: 4)
- VCR.use_cassette 'high_quality' do
- described_class.create_alerts_for_course_articles
- end
+ described_class.create_alerts_for_course_articles
expect(Alert.last.email_sent_at).not_to be_nil
end
@@ -76,9 +70,7 @@
Alert.create(type: 'HighQualityArticleAssignmentAlert', article_id: assignment.article_id,
course_id: assignment.course_id, user_id: assignment.user_id)
expect(HighQualityArticleAssignmentAlert.count).to eq(1)
- VCR.use_cassette 'high_quality' do
- described_class.create_alerts_for_course_articles
- end
+ described_class.create_alerts_for_course_articles
expect(HighQualityArticleAssignmentAlert.count).to eq(1)
end
@@ -94,7 +86,17 @@
it 'does not create second Alert if the first alert is resolved but there are no new edits' do
Alert.create(type: 'HighQualityArticleEditAlert', article_id: article.id,
- course_id: course.id, resolved: true, created_at: revision.date + 1.hour)
+ course_id: course.id, resolved: true, created_at: course.end - 1.minute)
+ expect(HighQualityArticleEditAlert.count).to eq(1)
+ VCR.use_cassette 'high_quality' do
+ described_class.create_alerts_for_course_articles
+ end
+ expect(HighQualityArticleEditAlert.count).to eq(1)
+ end
+
+ it 'does not create second Alert if the first alert is resolved but no new student edits' do
+ Alert.create(type: 'HighQualityArticleEditAlert', article_id: article.id,
+ course_id: course.id, resolved: true, created_at: course.start + 2.months)
expect(HighQualityArticleEditAlert.count).to eq(1)
VCR.use_cassette 'high_quality' do
described_class.create_alerts_for_course_articles
@@ -102,9 +104,9 @@
expect(HighQualityArticleEditAlert.count).to eq(1)
end
- it 'does create second Alert if the first alert is resolved and there are later edits' do
+ it 'does create second Alert if the first alert is resolved and later student edits' do
Alert.create(type: 'HighQualityArticleEditAlert', article_id: article.id,
- course_id: course.id, resolved: true, created_at: revision.date - 1.hour)
+ course_id: course.id, resolved: true, created_at: course.start + 1.minute)
expect(HighQualityArticleEditAlert.count).to eq(1)
VCR.use_cassette 'high_quality' do
described_class.create_alerts_for_course_articles
diff --git a/spec/lib/importers/user_importer_spec.rb b/spec/lib/importers/user_importer_spec.rb
index fdb4d596d7..83ece5ff30 100644
--- a/spec/lib/importers/user_importer_spec.rb
+++ b/spec/lib/importers/user_importer_spec.rb
@@ -180,6 +180,16 @@
describe '.update_user_from_wiki' do
let(:course) { create(:course) }
+ let(:enwiki) { Wiki.get_or_create(language: 'en', project: 'wikipedia') }
+
+ context 'when get_user_info returns nil' do
+ it 'returns early without raising an error and does not update the user' do
+ user = create(:user, username: 'RageSoss')
+ allow_any_instance_of(WikiApi).to receive(:get_user_info).and_return(nil)
+ expect(user).not_to receive(:update!)
+ expect { described_class.update_user_from_wiki(user, enwiki) }.not_to raise_error
+ end
+ end
it 'cleans up records when there are collisions' do
VCR.use_cassette 'user/new_from_renamed_user' do
@@ -197,7 +207,6 @@
it 'sets the registration date from English Wikipedia' do
VCR.use_cassette 'user/enwiki_only_account' do
user = create(:user, username: 'Brady2421')
- enwiki = Wiki.get_or_create(language: 'en', project: 'wikipedia')
described_class.update_user_from_wiki(user, enwiki)
expect(user.registered_at).not_to be_nil
end
diff --git a/spec/lib/lift_wing_api_spec.rb b/spec/lib/lift_wing_api_spec.rb
index 487fbc9f29..737483e39b 100644
--- a/spec/lib/lift_wing_api_spec.rb
+++ b/spec/lib/lift_wing_api_spec.rb
@@ -25,9 +25,6 @@
# Get revision data for valid rev ids for English Wikipedia
let(:subject0) { lift_wing_api_class_en_wiki.get_revision_data(rev_ids) }
- # Get revision data for valid rev ids for Wikidata
- let(:subject1) { described_class.new(wiki).get_revision_data(rev_ids) }
-
# Get revision data for deleted rev ids for English Wikipedia
let(:subject2) { lift_wing_api_class_en_wiki.get_revision_data([deleted_rev_id]) }
@@ -47,8 +44,16 @@
end
end
- it 'fetches json from api.wikimedia.org for wikidata' do
- VCR.use_cassette 'liftwing_api/wikidata' do
+ context 'fetch json data from api.wikimedia.org' do
+ before do
+ stub_wiki_validation
+ stub_lift_wing_response
+ end
+
+ # Get revision data for valid rev ids for Wikidata
+ let(:subject1) { described_class.new(wiki).get_revision_data([829840084, 829840085]) }
+
+ it 'fetches data for wikidata' do
expect(subject1).to be_a(Hash)
expect(subject1.dig('829840084')).to have_key('wp10')
expect(subject1.dig('829840084', 'wp10')).to eq(nil)
diff --git a/spec/lib/personal_data/personal_data_csv_builder_spec.rb b/spec/lib/personal_data/personal_data_csv_builder_spec.rb
new file mode 100644
index 0000000000..e4175218a6
--- /dev/null
+++ b/spec/lib/personal_data/personal_data_csv_builder_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require "#{Rails.root}/lib/personal_data/personal_data_csv_builder"
+
+describe PersonalData::PersonalDataCsvBuilder, type: :request do
+ let(:user) do
+ create(:user,
+ username: 'Sage the Rage',
+ real_name: 'Sage Ross',
+ email: 'sage@example.com',
+ created_at: '2025-01-28 16:04:05 UTC',
+ updated_at: '2025-01-28 16:04:05 UTC',
+ locale: 'en',
+ first_login: '2025-01-27 16:04:05 UTC')
+ end
+
+ it 'logs in as the user, downloads personal data in CSV, and checks its content' do
+ login_as(user)
+
+ csv_content = described_class.new(user).generate_csv
+
+ csv_lines = csv_content.split("\n")
+
+ expect(csv_lines.count).to be >= 2
+
+ expect(csv_lines[0]).to include(
+ 'Username',
+ 'Real Name',
+ 'Email',
+ 'Created At',
+ 'Updated At',
+ 'Locale',
+ 'First Login'
+ )
+
+ expect(csv_lines[1]).to include(
+ 'Sage the Rage',
+ 'Sage Ross',
+ 'sage@example.com',
+ '2025-01-28 16:04:05 UTC',
+ '2025-01-28 16:04:05 UTC',
+ 'en',
+ '2025-01-27 16:04:05 UTC'
+ )
+ end
+end
diff --git a/spec/lib/reference_counter_api_spec.rb b/spec/lib/reference_counter_api_spec.rb
index 0b4cd20e7c..69d3ee1784 100644
--- a/spec/lib/reference_counter_api_spec.rb
+++ b/spec/lib/reference_counter_api_spec.rb
@@ -11,6 +11,7 @@
let(:wikidata) { Wiki.get_or_create(language: nil, project: 'wikidata') }
let(:deleted_rev_ids) { [708326238] }
let(:rev_ids) { [5006940, 5006942, 5006946] }
+ let(:response) { stub_reference_counter_response }
it 'raises InvalidProjectError if using wikidata project' do
expect do
@@ -18,12 +19,20 @@
end.to raise_error(described_class::InvalidProjectError)
end
- it 'returns the number of references if response is 200 OK', vcr: true do
- ref_counter_api = described_class.new(es_wiktionary)
- response = ref_counter_api.get_number_of_references_from_revision_ids rev_ids
- expect(response.dig('5006940', 'num_ref')).to eq(10)
- expect(response.dig('5006942', 'num_ref')).to eq(4)
- expect(response.dig('5006946', 'num_ref')).to eq(2)
+ context 'returns the number of references' do
+ before do
+ stub_wiki_validation
+ stub_reference_counter_response
+ end
+
+ # Get revision data for valid rev ids for Wikidata
+ it 'if response is 200 OK', vcr: true do
+ ref_counter_api = described_class.new(es_wiktionary)
+ response = ref_counter_api.get_number_of_references_from_revision_ids rev_ids
+ expect(response.dig('5006940', 'num_ref')).to eq(10)
+ expect(response.dig('5006942', 'num_ref')).to eq(4)
+ expect(response.dig('5006946', 'num_ref')).to eq(2)
+ end
end
# it 'logs the message if response is not 200 OK', vcr: true do
diff --git a/spec/lib/revision_score_api_handler_spec.rb b/spec/lib/revision_score_api_handler_spec.rb
index 2292f50f7b..c77c6461d6 100644
--- a/spec/lib/revision_score_api_handler_spec.rb
+++ b/spec/lib/revision_score_api_handler_spec.rb
@@ -9,7 +9,7 @@
let(:subject) { handler.get_revision_data [829840090, 829840091] }
describe '#get_revision_data' do
- it 'returns completed scores if retrieves data without errors' do
+ it 'returns completed scores if data is retrieved without errors' do
VCR.use_cassette 'revision_score_api_handler/en_wikipedia' do
expect(subject).to be_a(Hash)
expect(subject.dig('829840090', 'wp10').to_f).to be_within(0.01).of(62.81)
@@ -28,15 +28,26 @@
end
end
- it 'returns completed scores if there is an error hitting LiftWingApi' do
- VCR.use_cassette 'revision_score_api_handler/en_wikipedia_liftwing_error' do
- stub_request(:any, /.*api.wikimedia.org.*/)
- .to_raise(Errno::ETIMEDOUT)
- expect(subject).to be_a(Hash)
- expect(subject.dig('829840090')).to eq({ 'wp10' => nil,
- 'features' => { 'num_ref' => 132 }, 'deleted' => false, 'prediction' => nil })
- expect(subject.dig('829840091')).to eq({ 'wp10' => nil,
- 'features' => { 'num_ref' => 1 }, 'deleted' => false, 'prediction' => nil })
+ describe 'error hitting LiftWingApi' do
+ before do
+ stub_wiki_validation
+ stub_revision_score_reference_counter_reponse
+ end
+
+ let(:wiki) { create(:wiki, project: 'wikipedia', language: 'es') }
+ let(:handler) { described_class.new(wiki:) }
+ let(:subject) { handler.get_revision_data [829840090, 829840091] }
+
+ it 'returns completed scores if there is an error hitting LiftWingApi' do
+ VCR.use_cassette 'revision_score_api_handler/en_wikipedia_liftwing_error' do
+ stub_request(:any, /.*api.wikimedia.org.*/)
+ .to_raise(Errno::ETIMEDOUT)
+ expect(subject).to be_a(Hash)
+ expect(subject.dig('829840090')).to eq({ 'wp10' => nil,
+ 'features' => { 'num_ref' => 132 }, 'deleted' => false, 'prediction' => nil })
+ expect(subject.dig('829840091')).to eq({ 'wp10' => nil,
+ 'features' => { 'num_ref' => 1 }, 'deleted' => false, 'prediction' => nil })
+ end
end
end
@@ -76,34 +87,36 @@
end
context 'when the wiki is available only for LiftWing API' do
- before { stub_wiki_validation }
-
let(:wiki) { create(:wiki, project: 'wikidata', language: nil) }
let(:handler) { described_class.new(wiki:) }
- let(:subject) { handler.get_revision_data [144495297, 144495298] }
+
+ before do
+ stub_wiki_validation
+ stub_revision_score_lift_wing_reponse
+ end
describe '#get_revision_data' do
- it 'returns completed scores if retrieves data without errors' do
- VCR.use_cassette 'revision_score_api_handler/wikidata' do
- expect(subject).to be_a(Hash)
- expect(subject.dig('144495297', 'wp10').to_f).to eq(0)
- expect(subject.dig('144495297', 'features')).to be_a(Hash)
- expect(subject.dig('144495297', 'features',
- 'feature.len()')).to eq(2)
- # 'num_ref' key doesn't exist for wikidata features
- expect(subject.dig('144495297', 'features').key?('num_ref')).to eq(false)
- expect(subject.dig('144495297', 'deleted')).to eq(false)
- expect(subject.dig('144495297', 'prediction')).to eq('D')
-
- expect(subject.dig('144495298', 'wp10').to_f).to eq(0)
- expect(subject.dig('144495298', 'features')).to be_a(Hash)
- expect(subject.dig('144495298', 'features',
- 'feature.len()')).to eq(0)
- # 'num_ref' key doesn't exist for wikidata features
- expect(subject.dig('144495298', 'features').key?('num_ref')).to eq(false)
- expect(subject.dig('144495298', 'deleted')).to eq(false)
- expect(subject.dig('144495298', 'prediction')).to eq('E')
- end
+ let(:subject) { handler.get_revision_data [144495297, 144495298] }
+
+ it 'returns completed scores if data is retrieved without errors' do
+ expect(subject).to be_a(Hash)
+ expect(subject.dig('144495297', 'wp10').to_f).to eq(0)
+ expect(subject.dig('144495297', 'features')).to be_a(Hash)
+ expect(subject.dig('144495297', 'features',
+ 'feature.len()')).to eq(2)
+ # 'num_ref' key doesn't exist for wikidata features
+ expect(subject.dig('144495297', 'features').key?('num_ref')).to eq(false)
+ expect(subject.dig('144495297', 'deleted')).to eq(false)
+ expect(subject.dig('144495297', 'prediction')).to eq('D')
+
+ expect(subject.dig('144495298', 'wp10').to_f).to eq(0)
+ expect(subject.dig('144495298', 'features')).to be_a(Hash)
+ expect(subject.dig('144495298', 'features',
+ 'feature.len()')).to eq(0)
+ # 'num_ref' key doesn't exist for wikidata features
+ expect(subject.dig('144495298', 'features').key?('num_ref')).to eq(false)
+ expect(subject.dig('144495298', 'deleted')).to eq(false)
+ expect(subject.dig('144495298', 'prediction')).to eq('E')
end
it 'returns completed scores if there is an error hitting LiftWingApi' do
@@ -119,7 +132,10 @@
end
context 'when the wiki is available only for reference-counter API' do
- before { stub_wiki_validation }
+ before do
+ stub_wiki_validation
+ stub_revision_score_reference_counter_reponse
+ end
let(:wiki) { create(:wiki, project: 'wikipedia', language: 'es') }
let(:handler) { described_class.new(wiki:) }
@@ -127,13 +143,11 @@
describe '#get_revision_data' do
it 'returns completed scores if retrieves data without errors' do
- VCR.use_cassette 'revision_score_api_handler/es_wikipedia' do
- expect(subject).to be_a(Hash)
- expect(subject.dig('157412237')).to eq({ 'wp10' => nil,
- 'features' => { 'num_ref' => 111 }, 'deleted' => false, 'prediction' => nil })
- expect(subject.dig('157417768')).to eq({ 'wp10' => nil,
- 'features' => { 'num_ref' => 42 }, 'deleted' => false, 'prediction' => nil })
- end
+ expect(subject).to be_a(Hash)
+ expect(subject.dig('157412237')).to eq({ 'wp10' => nil,
+ 'features' => { 'num_ref' => 111 }, 'deleted' => false, 'prediction' => nil })
+ expect(subject.dig('157417768')).to eq({ 'wp10' => nil,
+ 'features' => { 'num_ref' => 42 }, 'deleted' => false, 'prediction' => nil })
end
it 'returns completed scores if there is an error hitting reference-counter api' do
diff --git a/spec/lib/wiki_api_spec.rb b/spec/lib/wiki_api_spec.rb
index 73ac618f1a..d98e0f40b5 100644
--- a/spec/lib/wiki_api_spec.rb
+++ b/spec/lib/wiki_api_spec.rb
@@ -6,33 +6,45 @@
class UnexpectedError < StandardError; end
describe WikiApi do
- describe 'error handling and calls ApiErrorHandling method' do
+ describe 'handles errors by calling ApiErrorHandling method and raising a PageFetchError' do
let(:subject) { described_class.new.get_page_content('Ragesoss') }
it 'handles mediawiki 503 errors gracefully' do
stub_wikipedia_503_error
- expect(subject).to eq(nil)
+ expect { subject }.to raise_error(
+ WikiApi::PageFetchError,
+ /Failed to fetch content for Ragesoss with response status: 503/
+ )
end
it 'handles timeout errors gracefully' do
allow_any_instance_of(MediawikiApi::Client).to receive(:send)
.and_raise(Faraday::TimeoutError)
expect_any_instance_of(described_class).to receive(:log_error).once
- expect(subject).to eq(nil)
+ expect { subject }.to raise_error(
+ WikiApi::PageFetchError,
+ /Failed to fetch content for Ragesoss with response status: nil/
+ )
end
it 'handles API errors gracefully' do
allow_any_instance_of(MediawikiApi::Client).to receive(:send)
.and_raise(MediawikiApi::ApiError)
expect_any_instance_of(described_class).to receive(:log_error).once
- expect(subject).to eq(nil)
+ expect { subject }.to raise_error(
+ WikiApi::PageFetchError,
+ /Failed to fetch content for Ragesoss with response status: nil/
+ )
end
it 'handles HTTP errors gracefully' do
allow_any_instance_of(MediawikiApi::Client).to receive(:send)
.and_raise(MediawikiApi::HttpError, '')
expect_any_instance_of(described_class).to receive(:log_error).once
- expect(subject).to eq(nil)
+ expect { subject }.to raise_error(
+ WikiApi::PageFetchError,
+ /Failed to fetch content for Ragesoss with response status: nil/
+ )
end
end
@@ -169,6 +181,18 @@ class UnexpectedError < StandardError; end
end
end
+ describe '#get_user_info' do
+ let(:wiki) { Wiki.new(language: 'en', project: 'wikipedia') }
+
+ context 'when mediawiki query returns nil' do
+ it 'returns early without raising an error' do
+ allow_any_instance_of(described_class).to receive(:mediawiki).and_return(nil)
+ expect { described_class.new.get_user_info('Ragesoss') }.not_to raise_error
+ expect(described_class.new.get_user_info('Ragesoss')).to be_nil
+ end
+ end
+ end
+
describe '#redirect?' do
let(:wiki) { Wiki.new(language: 'en', project: 'wikipedia') }
let(:subject) { described_class.new(wiki).redirect?(title) }
diff --git a/spec/mailers/previews/enrollment_reminder_mailer_preview.rb b/spec/mailers/previews/enrollment_reminder_mailer_preview.rb
new file mode 100644
index 0000000000..1df2e243aa
--- /dev/null
+++ b/spec/mailers/previews/enrollment_reminder_mailer_preview.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class EnrollmentReminderMailerPreview < ActionMailer::Preview
+ def enrollment_reminder_email
+ EnrollmentReminderMailer.email(example_user)
+ end
+
+ private
+
+ def example_user
+ User.new(email: 'sage@example.com', username: 'Ragesoss')
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index a89ea159a5..affd03f515 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -24,6 +24,15 @@
Rails.cache.clear
Capybara::Screenshot.prune_strategy = :keep_last_run
+Capybara::Screenshot.register_filename_prefix_formatter(:rspec) do |example|
+ file_name = example.file_path.split('/').last.gsub('.rb', '')
+ formatted_description = example.description
+ .tr(' ', '-')
+ .gsub(%r{^.*/spec/}, '')
+ .gsub(/["*:<>|?\\\r\n]/, '')
+ "screenshot_#{file_name}_description_#{formatted_description}"
+end
+
Capybara.save_path = 'tmp/screenshots/'
Capybara.server = :puma, { Silent: true }
Capybara.default_max_wait_time = 10
@@ -98,7 +107,6 @@
# them.
# Instead, we clear and print any after-success error
# logs in the `before` block above.
- Capybara::Screenshot.screenshot_and_save_page if example.exception
errors = page.driver.browser.logs.get(:browser)
# pass `js_error_expected: true` to skip JS error checking
diff --git a/spec/services/system_metrics_spec.rb b/spec/services/system_metrics_spec.rb
new file mode 100644
index 0000000000..95332fec30
--- /dev/null
+++ b/spec/services/system_metrics_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe SystemMetrics do
+ let(:service) { described_class.new }
+
+ describe '#initialize' do
+ it 'initializes with valid Sidekiq queue data' do
+ queues = YAML.load_file('config/sidekiq.yml')[:queues].reject { |q| q == 'very_long_update' }
+ expect(service.instance_variable_get(:@queues)).to eq(queues)
+ end
+ end
+
+ describe '#fetch_sidekiq_stats' do
+ it 'returns Sidekiq stats with enqueued and active jobs' do
+ stats = service.fetch_sidekiq_stats
+ expect(stats).to be_a(Hash)
+ expect(stats).to include(:enqueued_jobs, :active_jobs)
+ end
+ end
+
+ describe '#fetch_queue_management_metrics' do
+ it 'returns queue metrics with valid data' do
+ metrics = service.fetch_queue_management_metrics
+ expect(metrics).to be_a(Hash)
+ expect(metrics).to include(:queues, :paused_queues, :all_operational)
+
+ metrics[:queues].each do |queue|
+ expect(queue).to include(:name, :size, :status, :latency)
+ expect(queue[:latency]).to be_a(String)
+ end
+ end
+
+ it 'identifies paused queues correctly' do
+ allow_any_instance_of(Sidekiq::Queue).to receive(:paused?).and_return(true)
+ metrics = service.fetch_queue_management_metrics
+ expect(metrics[:paused_queues]).not_to be_empty
+ expect(metrics[:all_operational]).to eq(false)
+ end
+ end
+
+ describe '#get_queue_data' do
+ it 'returns data for a single queue' do
+ queue_name = service.instance_variable_get(:@queues).first
+ queue = Sidekiq::Queue.new(queue_name)
+ queue_data = service.get_queue_data(queue)
+
+ expect(queue_data).to be_a(Hash)
+ expect(queue_data).to include(:name, :size, :status, :latency)
+ end
+ end
+
+ describe '#get_queue_status' do
+ it 'returns Normal for queues under threshold latency' do
+ expect(service.get_queue_status('short_update', 1.hour)).to eq('Normal')
+ expect(service.get_queue_status('medium_update', 6.hours)).to eq('Normal')
+ expect(service.get_queue_status('long_update', 12.hours)).to eq('Normal')
+ expect(service.get_queue_status('daily_update', 12.hours)).to eq('Normal')
+ expect(service.get_queue_status('constant_update', 12.minutes)).to eq('Normal')
+ expect(service.get_queue_status('default', 0)).to eq('Normal')
+ end
+
+ it 'returns Backlogged for queues exceeding threshold latency' do
+ expect(service.get_queue_status('short_update', 3.hours)).to eq('Backlogged')
+ expect(service.get_queue_status('medium_update', 13.hours)).to eq('Backlogged')
+ expect(service.get_queue_status('long_update', 26.hours)).to eq('Backlogged')
+ expect(service.get_queue_status('daily_update', 26.hours)).to eq('Backlogged')
+ expect(service.get_queue_status('constant_update', 16.minutes)).to eq('Backlogged')
+ expect(service.get_queue_status('default', 2)).to eq('Backlogged')
+ end
+ end
+
+ describe '#convert_latency' do
+ it 'converts latency in seconds to more readable formats' do
+ expect(service.convert_latency(30)).to eq('30 seconds')
+ expect(service.convert_latency(90)).to eq('1 minute 30 seconds')
+ expect(service.convert_latency(3600)).to eq('1 hour')
+ expect(service.convert_latency(90000)).to eq('1 day 1 hour')
+ end
+ end
+
+ describe '#format_time' do
+ it 'formats time into main and sub-units' do
+ formatted_time = service.format_time(3661, 3600, 'hour', 'minute')
+ expect(formatted_time).to eq('1 hour 1 minute')
+ end
+ end
+end
diff --git a/spec/services/update_course_stats_spec.rb b/spec/services/update_course_stats_spec.rb
index 361c74ecff..aa09fd3d6c 100644
--- a/spec/services/update_course_stats_spec.rb
+++ b/spec/services/update_course_stats_spec.rb
@@ -1,5 +1,4 @@
# frozen_string_literal: true
-
require 'rails_helper'
describe UpdateCourseStats do
@@ -81,13 +80,18 @@
it 'tracks update errors properly in Replica' do
allow(Sentry).to receive(:capture_exception)
+ # Stub the constant RETRY_COUNT for this test to ensure retries are controlled
+ stub_const('LiftWingApi::RETRY_COUNT', 1)
+
# Raising errors only in Replica
stub_request(:any, %r{https://replica-revision-tools.wmcloud.org/.*}).to_raise(Errno::ECONNREFUSED)
VCR.use_cassette 'course_update/replica' do
subject
end
sentry_tag_uuid = subject.sentry_tag_uuid
- expect(course.flags['update_logs'][1]['error_count']).to eq 1
+ expected_error_count = subject.error_count
+
+ expect(course.flags['update_logs'][1]['error_count']).to eq expected_error_count
expect(course.flags['update_logs'][1]['sentry_tag_uuid']).to eq sentry_tag_uuid
# Checking whether Sentry receives correct error and tags as arguments
@@ -100,18 +104,20 @@
it 'tracks update errors properly in LiftWing' do
allow(Sentry).to receive(:capture_exception)
+ stub_const('LiftWingApi::RETRY_COUNT', 1)
# Raising errors only in LiftWing
stub_request(:any, %r{https://api.wikimedia.org/service/lw.*}).to_raise(Faraday::ConnectionFailed)
VCR.use_cassette 'course_update/lift_wing_api' do
subject
end
sentry_tag_uuid = subject.sentry_tag_uuid
- expect(course.flags['update_logs'][1]['error_count']).to eq 8
+ expected_error_count = subject.error_count
+ expect(course.flags['update_logs'][1]['error_count']).to eq expected_error_count
expect(course.flags['update_logs'][1]['sentry_tag_uuid']).to eq sentry_tag_uuid
# Checking whether Sentry receives correct error and tags as arguments
expect(Sentry).to have_received(:capture_exception)
- .exactly(8).times.with(Faraday::ConnectionFailed, anything)
+ .exactly(expected_error_count).times.with(Faraday::ConnectionFailed, anything)
expect(Sentry).to have_received(:capture_exception)
.exactly(8).times.with anything, hash_including(tags: { update_service_id: sentry_tag_uuid,
course: course.slug })
diff --git a/spec/support/request_helpers.rb b/spec/support/request_helpers.rb
index 347b81e874..56e9d54859 100644
--- a/spec/support/request_helpers.rb
+++ b/spec/support/request_helpers.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+require 'json' # Ensure JSON is required for to_json
#= Stubs for various requests
module RequestHelpers
@@ -561,4 +562,159 @@ def stub_course
stub_timeline
stub_users
end
+
+ def stub_lift_wing_response
+ request_body = {
+ 'wikidatawiki' => {
+ 'models' => {
+ 'itemquality' => {
+ 'version' => '0.5.0'
+ }
+ },
+ 'scores' => {
+ '829840084' => {
+ 'itemquality' => {
+ 'score' => {
+ 'prediction' => 'D',
+ 'probability' => { 'A' => 0.001863543366261331, 'B' => 0.001863543366261331 }
+ },
+ 'features' => {
+ 'feature.len()' => 3.0,
+ 'feature.len()' => 3.0,
+ 'feature.len()' => 0.0
+ }
+ }
+ },
+ '829840085' => {
+ 'itemquality' => {
+ 'score' => {
+ 'prediction' => 'D',
+ 'probability' => { 'A' => 0.005396336449201622, 'B' => 0.005396336449201622 }
+ },
+ 'features' => {
+ 'feature.len()' => 10.0,
+ 'feature.len()' => 9.0,
+ 'feature.len()' => 1.0
+ }
+ }
+ }
+ }
+ }
+ }
+ stub_request(:post, 'https://api.wikimedia.org/service/lw/inference/v1/models/wikidatawiki-itemquality:predict')
+ .with(
+ body: hash_including(extended_output: true),
+ headers: { 'Content-Type': 'application/json' }
+ ).to_return(
+ status: 200,
+ body: request_body.to_json
+ )
+ end
+
+ def stub_reference_counter_response
+ # Define the response body in a hash with revision IDs as keys
+ request_body = {
+ '5006940' => { 'num_ref' => 10, 'lang' => 'es', 'project' => 'wiktionary',
+ 'revid' => 5006940 },
+ '5006942' => { 'num_ref' => 4, 'lang' => 'es', 'project' => 'wiktionary',
+ 'revid' => 5006942 },
+ '5006946' => { 'num_ref' => 2, 'lang' => 'es', 'project' => 'wiktionary', 'revid' => 5006946 }
+ }
+
+ # Stub the request to match the revision ID in the URL
+ stub_request(:get, %r{https://reference-counter.toolforge.org/api/v1/references/wiktionary/es/\d+})
+ .to_return(
+ status: 200,
+ body: lambda do |request|
+ # Extract revision ID from the URL
+ rev_id = request.uri.path.split('/').last
+ # Return the appropriate response based on the revision ID
+ { 'num_ref' => request_body[rev_id.to_s]['num_ref'] }.to_json
+ end,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ def stub_revision_score_lift_wing_reponse
+ request_body =
+ {
+ 'wikidatawiki' => {
+ 'models' => {
+ 'itemquality' => {
+ 'version' => '0.5.0'
+ }
+ },
+ 'scores' => {
+ '144495297' => {
+ 'itemquality' => {
+ 'score' => {
+ 'prediction' => 'D',
+ 'probability' => {
+ 'A' => 0.004943068308984735,
+ 'B' => 0.004943068308984735
+ }
+ },
+ 'features' => {
+ 'feature.len()' => 3.0,
+ 'feature.len()' => 3.0,
+ 'feature.len()' => 2.0,
+ 'feature.len()' => 2.0
+ }
+ }
+ },
+ '144495298' => {
+ 'itemquality' => {
+ 'score' => {
+ 'prediction' => 'E',
+ 'probability' => {
+ 'A' => 0.0006501008909422321,
+ 'B' => 0.000887054617313177
+ }
+ },
+ 'features' => {
+ 'feature.len()' => 1.0,
+ 'feature.len()' => 1.0,
+ 'feature.len()' => 0.0,
+ 'feature.len()' => 0.0
+ }
+ }
+ }
+ }
+ }
+ }
+ stub_request(:post, 'https://api.wikimedia.org/service/lw/inference/v1/models/wikidatawiki-itemquality:predict')
+ .with(
+ body: hash_including(extended_output: true),
+ headers: { 'Content-Type': 'application/json' }
+ ).to_return(
+ status: 200,
+ body: request_body.to_json
+ )
+ end
+
+ def stub_revision_score_reference_counter_reponse
+ request_body = {
+ '157412237' => { 'num_ref' => 111, 'lang' => 'es', 'project' => 'wikipedia',
+ 'revid' => 157412237 },
+ '157417768' => { 'num_ref' => 42, 'lang' => 'es', 'project' => 'wikipedia',
+ 'revid' => 157417768 },
+ '829840090' => { 'num_ref' => 132, 'lang' => 'es', 'project' => 'wikipedia',
+ 'revid' => 829840090 },
+ '829840091' => { 'num_ref' => 1, 'lang' => 'es', 'project' => 'wikipedia',
+ 'revid' => 829840091 }
+ }
+
+ # Stub the request to match the revision ID in the URL
+ stub_request(:get, %r{https://reference-counter.toolforge.org/api/v1/references/wikipedia/es/\d+})
+ .to_return(
+ status: 200,
+ body: lambda do |request|
+ # Extract revision ID from the URL
+ rev_id = request.uri.path.split('/').last
+ # Return the appropriate response based on the revision ID
+ { 'num_ref' => request_body[rev_id.to_s]['num_ref'] }.to_json
+ end,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
end
diff --git a/test/reducers/notifications.spec.js b/test/reducers/notifications.spec.js
new file mode 100644
index 0000000000..48768ce83d
--- /dev/null
+++ b/test/reducers/notifications.spec.js
@@ -0,0 +1,241 @@
+import deepFreeze from 'deep-freeze';
+import notifications from '../../app/assets/javascripts/reducers/notifications';
+import { ADD_NOTIFICATION, REMOVE_NOTIFICATION, API_FAIL, SAVE_TIMELINE_FAIL } from '../../app/assets/javascripts/constants';
+
+describe('notifications reducer', () => {
+ let errorNotification1;
+ let errorNotification2;
+ let errorNotification3;
+ let successNotification1;
+ beforeEach(() => {
+ errorNotification1 = {
+ type: 'error',
+ message: 'Notification 1'
+ };
+ errorNotification2 = {
+ type: 'error',
+ message: 'Notification 2'
+ };
+ errorNotification3 = {
+ type: 'error',
+ message: 'Notification 3'
+ };
+ successNotification1 = {
+ type: 'success',
+ message: 'Notification 1'
+ };
+ });
+
+ it('should return the initial state', () => {
+ expect(notifications(undefined, {})).toEqual([]);
+ });
+
+ it('should handle ADD_NOTIFICATION when notifications < max', () => {
+ const initialState = [errorNotification1, errorNotification2, errorNotification3];
+ const action = {
+ type: ADD_NOTIFICATION,
+ notification: successNotification1
+ };
+ const expectedState = [...initialState, successNotification1];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle ADD_NOTIFICATION when notifications == max', () => {
+ const initialState = [errorNotification2, errorNotification1, errorNotification3];
+ const errorNotification4 = {
+ type: 'error',
+ message: 'Notification 4'
+ };
+ const action = {
+ type: ADD_NOTIFICATION,
+ notification: errorNotification4
+ };
+ const expectedState = [errorNotification1, errorNotification3, errorNotification4];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle REMOVE_NOTIFICATION', () => {
+ const expectedState = [errorNotification1, successNotification1, errorNotification3];
+ const initialState = [...expectedState, errorNotification2];
+ const action = {
+ type: REMOVE_NOTIFICATION,
+ notification: errorNotification2
+ };
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle API_FAIL for readyState 0', () => {
+ const initialState = [errorNotification2, successNotification1];
+ const action = {
+ type: API_FAIL,
+ data: {
+ readyState: 0
+ }
+ };
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(initialState);
+ });
+
+ it('should handle API_FAIL for silent action', () => {
+ const initialState = [errorNotification2, successNotification1];
+ const action = {
+ type: API_FAIL,
+ silent: true,
+ data: {
+ errors: 'Example notification error message'
+ }
+ };
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(initialState);
+ });
+
+ it('should handle API_FAIL for already saved notification', () => {
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: 'Example notification status'
+ };
+ const initialState = [notification];
+ const action = {
+ type: API_FAIL,
+ data: {
+ statusText: 'Example notification status'
+ }
+ };
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(initialState);
+ });
+
+ it('should handle API_FAIL for action with valid data.responseText', () => {
+ const initialState = [];
+ const action = {
+ type: API_FAIL,
+ data: {
+ responseText: '{"message": "Example notification status"}'
+ }
+ };
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: 'Example notification status'
+ };
+ const expectedState = [notification];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle API_FAIL for action with invalid data.responseText', () => {
+ const initialState = [];
+ const action = {
+ type: API_FAIL,
+ data: {
+ responseText: '{"message":"Example notification status"'
+ }
+ };
+ // when data.responseText is invalid, notification.message is assigned
+ // the data object as the last fallback. This is however serialized into
+ // a string as React cannot render objects.
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: '{"responseText":"{\\"message\\":\\"Example notification status\\""}',
+ };
+ const expectedState = [notification];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle API_FAIL for action with data.responseJSON.error', () => {
+ const initialState = [];
+ const action = {
+ type: API_FAIL,
+ data: {
+ responseJSON: {
+ error: 'Example notification status'
+ }
+ }
+ };
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: 'Example notification status'
+ };
+ const expectedState = [notification];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle API_FAIL for action with failed JSONP request', () => {
+ const initialState = [];
+ const action = {
+ type: API_FAIL,
+ data: {
+ message: 'JSONP request failed'
+ }
+ };
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: I18n.t('customize_error_message.JSONP_request_failed')
+ };
+ const expectedState = [notification];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle API_FAIL for action with empty data', () => {
+ const initialState = [];
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: ''
+ };
+ const action = {
+ type: API_FAIL,
+ data: ''
+ };
+ const expectedState = [notification];
+ const errorSpy = jest.spyOn(console, 'error');
+ const logSpy = jest.spyOn(console, 'log');
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ expect(errorSpy).toHaveBeenCalledWith('Error: ', '');
+ expect(logSpy).toHaveBeenCalledWith('');
+ });
+
+ it('should handle SAVE_TIMELINE_FAIL', () => {
+ const initialState = [errorNotification2, successNotification1];
+ deepFreeze(initialState);
+ const action = {
+ type: SAVE_TIMELINE_FAIL,
+ data: {},
+ courseSlug: {}
+ };
+ const notification = {
+ closable: true,
+ type: 'error',
+ message: 'The changes you just submitted were not saved. '
+ + 'This may happen if the timeline has been changed — '
+ + 'by someone else, or by you in another browser '
+ + 'window — since the page was loaded. The latest '
+ + 'course data has been reloaded, and is ready for '
+ + 'you to edit further.'
+ };
+ const expectedState = [...initialState, notification];
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle unknown action type', () => {
+ const initialState = [errorNotification2, successNotification1];
+ deepFreeze(initialState);
+ const action = {
+ type: 'UNKNOWN_ACTION'
+ };
+ deepFreeze(initialState);
+ expect(notifications(initialState, action)).toEqual(initialState);
+ });
+});
diff --git a/training_content/wiki_ed/slides/60-keeping-track-of-your-work/6003-stage-1-bibliography.yml b/training_content/wiki_ed/slides/60-keeping-track-of-your-work/6003-stage-1-bibliography.yml
index d2a8633f9a..4bcc294ae9 100644
--- a/training_content/wiki_ed/slides/60-keeping-track-of-your-work/6003-stage-1-bibliography.yml
+++ b/training_content/wiki_ed/slides/60-keeping-track-of-your-work/6003-stage-1-bibliography.yml
@@ -4,7 +4,7 @@ summary:
content: |
-
You can see the details for each stage of the article development process by clicking the bar
at the bottom of the article listing. For the first stage, compiling your set of sources,
diff --git a/training_content/wiki_ed/slides/65-keeping-track-of-work-without-sandboxes/6503-stage-1-bibliography-no-sandbox.yml b/training_content/wiki_ed/slides/65-keeping-track-of-work-without-sandboxes/6503-stage-1-bibliography-no-sandbox.yml
index 3f5357a413..51ba25a98b 100644
--- a/training_content/wiki_ed/slides/65-keeping-track-of-work-without-sandboxes/6503-stage-1-bibliography-no-sandbox.yml
+++ b/training_content/wiki_ed/slides/65-keeping-track-of-work-without-sandboxes/6503-stage-1-bibliography-no-sandbox.yml
@@ -4,7 +4,7 @@ summary:
content: |
-
You can see the details for each stage of the article development process by clicking the bar
at the bottom of the article listing. For the first stage, compiling your set of sources,
diff --git a/update_hosts.sh b/update_hosts.sh
new file mode 100644
index 0000000000..18cad09897
--- /dev/null
+++ b/update_hosts.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+
+# Get the MySQL container ID
+MYSQL_CONTAINER_ID=$(docker ps --format '{{.ID}} {{.Names}}' | grep 'wikiedudashboard-mysql-1' | awk '{print $1}')
+
+echo $MYSQL_CONTAINER_ID
+
+# Fetch the IP address of the MySQL container
+MYSQL_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$MYSQL_CONTAINER_ID")
+
+# Check if we successfully fetched the IP address
+if [ -z "$MYSQL_IP" ]; then
+ echo "Failed to retrieve the MySQL container IP address. Please ensure the Docker containers are running by executing 'docker compose up -d'."
+ if grep -q "$MYSQL_IP mysql" /etc/hosts; then
+ sudo sed -i '/[[:space:]]mysql$/d' /etc/hosts
+ echo "MySQL entry removed from /etc/hosts."
+ fi
+
+ exit 1
+fi
+
+# Print the IP address
+echo "MySQL IP address: $MYSQL_IP"
+
+# Backup /etc/hosts file
+sudo cp /etc/hosts /etc/hosts.bak
+
+# Add MySQL container IP to /etc/hosts
+if grep -q "$MYSQL_IP mysql" /etc/hosts; then
+ echo "MySQL entry already exists in /etc/hosts."
+ # Remove existing MySQL entry from /etc/hosts (if it exists)
+ sudo sed -i '/[[:space:]]mysql$/d' /etc/hosts
+ echo "Removed old MySQL entry from /etc/hosts."
+fi
+
+# Add new MySQL container IP address to /etc/hosts
+echo "Adding MySQL container IP address to /etc/hosts"
+echo "$MYSQL_IP mysql" | sudo tee -a /etc/hosts > /dev/null
+
+# Print success message
+echo "MySQL IP address has been added to /etc/hosts."
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 899bd04e7b..9bedc7f5f0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2817,6 +2817,7 @@ __metadata:
eslint-webpack-plugin: ^3.1.1
fetch-jsonp: ^1.2.1
i18n-js: ^4.2.2
+ immer: ^10.1.1
jeet: ^7.2.0
jest: ^26.0.1
jquery: ^3.5.1
@@ -6866,7 +6867,7 @@ __metadata:
languageName: node
linkType: hard
-"immer@npm:^10.0.3":
+"immer@npm:^10.0.3, immer@npm:^10.1.1":
version: 10.1.1
resolution: "immer@npm:10.1.1"
checksum: 07c67970b7d22aded73607193d84861bf786f07d47f7d7c98bb10016c7a88f6654ad78ae1e220b3c623695b133aabbf24f5eb8d9e8060cff11e89ccd81c9c10b
|