diff --git a/BackEndFlask/Functions/teamBulkUpload.py b/BackEndFlask/Functions/teamBulkUpload.py index cf93356c7..812c9eabf 100755 --- a/BackEndFlask/Functions/teamBulkUpload.py +++ b/BackEndFlask/Functions/teamBulkUpload.py @@ -5,6 +5,7 @@ from models.team_user import * from models.user_course import * from models.course import * +from models.queries import does_team_user_exist from Functions.test_files.PopulationFunctions import xlsx_to_csv from datetime import date @@ -35,14 +36,14 @@ def __expect(lst: list[list[str]], cols: int | None = None) -> list[str]: will modify the original list passed. """ hd: list[str] = lst.pop(0) - + # Clean the row - specifically handle 'Unnamed:' columns and empty strings cleaned = [] for x in hd: stripped = x.strip() if stripped and not stripped.startswith('Unnamed:'): cleaned.append(stripped) - + if cols is not None and len(cleaned) != cols: raise TooManyColumns(1, cols, len(cleaned)) return cleaned @@ -71,7 +72,16 @@ def __parse(lst: list[list[str]]) -> list[TBUTeam]: raise EmptyTeamName if team_name == "" else EmptyTAEmail teams.append(TBUTeam(team_name, ta, students)) students = [] - current_state = EXPECT_TA + + multiple_observers = True + if len(lst) > 2: + hd = __expect(lst) + lookAhead = __expect(lst) + lst.insert(0, lookAhead) + lst.insert(0, hd) + multiple_observers = len(hd) == len(lookAhead) == 1 + + current_state = EXPECT_TA if multiple_observers else EXPECT_TEAM continue # Process based on what type of row we're expecting @@ -263,10 +273,12 @@ def __handle_student(student: TBUStudent, team_name: str, tainfo): else: set_inactive_status_of_user_to_active(user_course.user_course_id) - create_team_user({ - "team_id": team_id, - "user_id": user_id - }) + # Prevents duplicaition in the team user table. + if not does_team_user_exist(user_id, team_id): + create_team_user({ + "team_id": team_id, + "user_id": user_id + }) tainfo = __handle_ta() diff --git a/BackEndFlask/controller/Routes/Rating_routes.py b/BackEndFlask/controller/Routes/Rating_routes.py index 0b62faa22..b4bd18895 100644 --- a/BackEndFlask/controller/Routes/Rating_routes.py +++ b/BackEndFlask/controller/Routes/Rating_routes.py @@ -58,7 +58,6 @@ def get_student_individual_ratings(): @jwt_required() @bad_token_check() @AuthCheck() -@admin_check() def student_view_feedback(): """ Description: @@ -67,7 +66,7 @@ def student_view_feedback(): used to calculate lag time. """ try: - user_id = request.json.get("user_id") + user_id = request.args.get("user_id") completed_assessment_id = request.json.get("completed_assessment_id") exists = check_feedback_exists(user_id, completed_assessment_id) diff --git a/BackEndFlask/models/completed_assessment.py b/BackEndFlask/models/completed_assessment.py index bcd4fef93..2603e6f74 100644 --- a/BackEndFlask/models/completed_assessment.py +++ b/BackEndFlask/models/completed_assessment.py @@ -49,7 +49,7 @@ def get_completed_assessment_count(assessment_task_id): @error_log def completed_assessment_exists(team_id, assessment_task_id, user_id): if (user_id == -1): # Team assessment, otherwise individual assessment - return CompletedAssessment.query.filter_by(team_id=team_id, assessment_task_id=assessment_task_id, user_id=user_id).first() + return CompletedAssessment.query.filter_by(team_id=team_id, assessment_task_id=assessment_task_id).first() else: return CompletedAssessment.query.filter_by(user_id=user_id, assessment_task_id=assessment_task_id).first() diff --git a/BackEndFlask/models/queries.py b/BackEndFlask/models/queries.py index 0c1a20e9a..d82338284 100644 --- a/BackEndFlask/models/queries.py +++ b/BackEndFlask/models/queries.py @@ -1188,4 +1188,30 @@ def get_students_for_emailing(is_teams: bool, completed_at_id: int = None, at_id CompletedAssessment.completed_assessment_id == completed_at_id ) - return student_info.all() \ No newline at end of file + return student_info.all() + +def does_team_user_exist(user_id:int, team_id:int): + """ + Description: + Returns true or false if the (user_id,team_id) entry exists in TeamUser table. + + Paramaters: + user_id: (User Id) + team_id: (Team Id) + + Returns: + (If a (user_id, team_id) entry is present in TeamUser table) + + Exceptions: + None except what the db or oem may raise. + """ + is_entry = db.session.query( + TeamUser.team_user_id + ).filter( + TeamUser.user_id == user_id, + TeamUser.team_id == team_id + ).all() + + if len(is_entry) == 0: + return False + return True \ No newline at end of file diff --git a/FrontEndReact/package.json b/FrontEndReact/package.json index 0861772c4..42651ac00 100644 --- a/FrontEndReact/package.json +++ b/FrontEndReact/package.json @@ -9,39 +9,41 @@ }, "dependencies": { "@babel/plugin-proposal-private-property-in-object": "", - "@babel/preset-react": "^7.23.3", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@fontsource/roboto": "^5.0.8", - "@mui/icons-material": "^5.14.12", - "@mui/material": "^5.14.12", - "@mui/x-date-pickers": "^6.18.3", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/user-event": "^13.5.0", - "bootstrap": "^5.2.3", - "date-fns": "^2.30.0", - "date-fns-tz": "^2.0.1", - "dayjs": "^1.11.10", - "dotenv": "^16.3.1", - "dotenv-expand": "^10.0.0", + "@babel/preset-react": "^7.25.9", + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@fontsource/roboto": "^5.1.0", + "@mui/icons-material": "^5.15.6", + "@mui/material": "^5.15.6", + "@mui/system": "^5.15.14", + "@mui/x-date-pickers": "^6.19.2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", + "bootstrap": "^5.3.3", + "date-fns": "^3.2.0", + "date-fns-tz": "^3.2.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "dotenv-expand": "^12.0.1", "eventsource-client": "^1.1.3", "jest-dom": "^4.0.0", "mui-datatables": "^4.3.0", - "mui-one-time-password-input": "^2.0.1", - "react": "^18.2.0", + "mui-one-time-password-input": "^3.0.1", + "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", - "react-cookie": "^6.1.1", - "react-datepicker": "^4.12.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.10.0", + "react-cookie": "^7.2.2", + "react-datepicker": "^7.5.0", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "react-script": "^2.0.5", "react-scripts": "^5.0.1", - "react-select": "^5.7.3", + "react-select": "^5.8.3", "react-validation": "^3.0.7", "recharts": "", "ts-node": "^10.9.2", - "universal-cookie": "^6.1.1", - "validator": "^13.9.0", - "web-vitals": "^2.1.4" + "universal-cookie": "^7.2.2", + "validator": "^13.12.0", + "web-vitals": "^4.2.4" }, "scripts": { "start": "react-scripts start", @@ -68,10 +70,14 @@ ] }, "devDependencies": { - "@babel/preset-typescript": "^7.23.3", - "@testing-library/react": "^14.1.2", + "@babel/preset-typescript": "^7.26.0", + "@testing-library/react": "^16.0.1", "jest": "^29.7.0", - "react-test-renderer": "^18.2.0", + "react-test-renderer": "^18.3.1", "resize-observer-polyfill": "1.5.1" + }, + "overrides": { + "nth-check": "^2.0.1", + "postcss": "^8.4.31" } } diff --git a/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js b/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js index 5228d0cec..b595fa676 100644 --- a/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js +++ b/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js @@ -7,7 +7,7 @@ import { genericResourceGET, genericResourcePOST, genericResourcePUT, getDueDate import { Box, Button, FormControl, Typography, IconButton, TextField, Tooltip, FormControlLabel, Checkbox, MenuItem, Select, InputLabel, Radio, RadioGroup, FormLabel, FormGroup } from '@mui/material'; import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'; import ImageModal from "../AddCustomRubric/CustomRubricModal.js"; import RubricDescriptionsImage from "../../../../../src/RubricDetailedOverview.png"; import FormHelperText from '@mui/material/FormHelperText'; diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js index d8dab614a..8f5096a23 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js @@ -6,7 +6,7 @@ import { Box, Tab, Button } from '@mui/material'; import Tabs, { tabsClasses } from '@mui/material/Tabs'; import UnitOfAssessmentTab from './UnitOfAssessmentTab.js'; import StatusIndicator, { StatusIndicatorState } from './StatusIndicator.js'; -import { genericResourcePOST, genericResourcePUT } from '../../../../utility.js'; +import { genericResourcePOST, genericResourcePUT, debounce } from '../../../../utility.js'; import Cookies from 'universal-cookie'; import Alert from '@mui/material/Alert'; import { getUnitCategoryStatus } from './cat_utils.js'; @@ -28,6 +28,8 @@ import { getUnitCategoryStatus } from './cat_utils.js'; * @property {number} state.currentCategoryTabIndex - Index of the currently selected rubric `categoryList`. * @property {Object} state.section - Section object of the category `currentCategoryTabIndex` from `categoryList`. * @property {boolean} state.displaySavedNotification - Boolean indicating whether to display the pop-up window that confirms the assessment is saved. + * + * @property {Set} unitsThatNeedSaving - A set of all the unit indexes that need saving for autosave. */ class Form extends Component { constructor(props) { @@ -39,8 +41,10 @@ class Form extends Component { categoryList: null, currentCategoryTabIndex: 0, section: null, - displaySavedNotification: false - } + displaySavedNotification: false, + }; + + this.unitsThatNeedSaving = new Set(); /** * @method handleUnitTabChange - Handles the change of the unit tab. @@ -53,7 +57,9 @@ class Form extends Component { currentUnitTabIndex: newUnitTabIndex, currentCategoryTabIndex: 0, }, - this.generateCategoriesAndSection + () => { + this.generateCategoriesAndSection(); + } ); } }; @@ -68,7 +74,9 @@ class Form extends Component { { currentCategoryTabIndex: newCategoryTabIndex, }, - this.generateCategoriesAndSection + () => { + this.generateCategoriesAndSection(); + } ); } }; @@ -161,7 +169,7 @@ class Form extends Component { key={index} modifyUnitCategoryProperty={this.modifyUnitCategoryProperty} - handleSubmit={this.handleSubmit} + markForAutosave={this.markForAutosave} />; } }); @@ -186,32 +194,37 @@ class Form extends Component { /** * - * @method handleSubmit - Handles the submission of the form. - * @param {boolean} newIsDone - The new completion status of the unit. + * @method saveUnit - Saves a unit to the server. + * @param {number} unitIndex - The index of the unit to save. + * @param {boolean} markDone - If the unit should be marked as done or retain the current done status. */ - this.handleSubmit = (newIsDone) => { + this.saveUnit = (unitIndex, markDone) => { const chosenAssessmentTaskId = this.props.navbar.state.chosenAssessmentTask["assessment_task_id"]; - const selectedUnitIndex = this.state.currentUnitTabIndex; - const selectedUnit = this.state.units[selectedUnitIndex]; + const unit = this.state.units[unitIndex]; const cookies = new Cookies(); const currentUserId = cookies.get("user")["user_id"]; const currentDate = new Date(); - const newCAT = selectedUnit.generateNewCAT(chosenAssessmentTaskId, currentUserId, currentDate, newIsDone); - const newUnit = selectedUnit.withNewCAT(newCAT); - - if (selectedUnit.completedAssessmentTask) { - const catId = selectedUnit.completedAssessmentTask["completed_assessment_id"]; + // If markDone then mark the unit as done, otherwise use the original done status. + const newIsDone = markDone ? true : unit.isDone; + + const newUnit = unit.withNewIsDone(newIsDone); + const newCAT = newUnit.generateNewCAT(chosenAssessmentTaskId, currentUserId, currentDate); + + let promise; + + if (unit.completedAssessmentTask) { + const catId = unit.completedAssessmentTask["completed_assessment_id"]; - genericResourcePUT( + promise = genericResourcePUT( `/completed_assessment?completed_assessment_id=${catId}`, this, JSON.stringify(newCAT), { rawResponse: true } ); } else { - genericResourcePOST( + promise = genericResourcePOST( `/completed_assessment?assessment_task_id=${chosenAssessmentTaskId}&${newUnit.getSubmitQueryParam()}`, this, JSON.stringify(newCAT), @@ -224,7 +237,7 @@ class Form extends Component { prevState => { const updatedUnits = [...prevState.units]; - updatedUnits[selectedUnitIndex] = newUnit; + updatedUnits[unitIndex] = newUnit; return { displaySavedNotification: true, @@ -233,12 +246,50 @@ class Form extends Component { } ); + // Once the CAT entry has been updated, insert the new CAT entry into the unit object + promise.then(result => { + const completeAssessmentEntry = result?.["content"]?.["completed_assessments"]?.[0]; // The backend returns a list of a single entry + + if (completeAssessmentEntry) { + this.setState( + prevState => { + const updatedUnits = [...prevState.units]; + + updatedUnits[unitIndex] = updatedUnits[unitIndex].withNewCAT(completeAssessmentEntry); + + return { units: updatedUnits }; + } + ); + } + }); + setTimeout(() => { this.setState({ displaySavedNotification: false }); }, 3000); }; + + /** + * @method markForAutosave - Marks a unit to be autosaving soon. + * @param {number} unitIndex - The index of the unit. + */ + this.markForAutosave = (unitIndex) => { + this.unitsThatNeedSaving.add(unitIndex); + + this.doAutosave(); + } + + /** + * @method doAutosave - Performs an autosave. + */ + this.doAutosave = debounce(() => { + this.unitsThatNeedSaving.forEach(unitIndex => { + this.saveUnit(unitIndex, false); + }); + + this.unitsThatNeedSaving.clear(); + }, 2000); } componentDidMount() { @@ -266,7 +317,7 @@ class Form extends Component { aria-label="saveButton" onClick={() => { - this.handleSubmit(this.areAllCategoriesCompleted()); + this.saveUnit(this.state.currentUnitTabIndex, this.areAllCategoriesCompleted()); }} disabled={!this.areAllCategoriesCompleted()} diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js index cea701101..13b5691d7 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js @@ -7,7 +7,6 @@ import Rating from './Rating.js'; import TextArea from './TextArea.js'; import Box from '@mui/material/Box'; import { FormControl } from '@mui/material'; -import { debounce } from '../../../../utility.js'; /** @@ -24,15 +23,15 @@ import { debounce } from '../../../../utility.js'; * * @param {Function} props.modifyUnitCategoryProperty - Function to handle the updating the category property * - * @param {Function} props.handleSubmit - Function to handle the submit + * @param {Function} props.markForAutosave - Function to mark a unit for autosaving. */ class Section extends Component { constructor(props) { super(props); - this.autosave = debounce(() => { - this.props.handleSubmit(this.props.isDone); - }, 2000); + this.autosave = () => { + this.props.markForAutosave(this.props.currentUnitTabIndex); + }; /** * @method setCategoryProperty - Handles updating the diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/unit.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/unit.js index c64668a1f..7fc7c670f 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/unit.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/unit.js @@ -209,7 +209,6 @@ export class ATUnit { const newUnit = this.shallowClone(); newUnit.rocsData = newRocs; - return newUnit; } @@ -231,14 +230,7 @@ export class ATUnit { */ withNewCAT(newCat) { const newUnit = this.shallowClone(); - newUnit.completedAssessmentTask = newCat; - - if (newCat && Object.keys(newCat).length > 0) { - newUnit.rocsData = newCat["rating_observable_characteristics_suggestions_data"]; - newUnit.isDone = newCat["done"]; - } - return newUnit; } @@ -267,16 +259,15 @@ export class ATUnit { * @param {number} assessmentTaskId The ID of the assessment task. * @param {number} completedBy The user ID of the user that completed this assessment task. * @param {Date} completedAt The date and time that this assessment task was completed/updated. - * @param {boolean} isDone If this unit is done. * @returns {object} */ - generateNewCAT(assessmentTaskId, completedBy, completedAt, isDone) { + generateNewCAT(assessmentTaskId, completedBy, completedAt) { if (this.completedAssessmentTask) { const newCAT = structuredClone(this.completedAssessmentTask); newCAT["rating_observable_characteristics_suggestions_data"] = this.rocsData; newCAT["last_update"] = completedAt; - newCAT["done"] = isDone; + newCAT["done"] = this.isDone; return newCAT; } else { @@ -286,7 +277,7 @@ export class ATUnit { "completed_by": completedBy, "initial_time": completedAt, "last_update": completedAt, - "done": isDone, + "done": this.isDone, }; } } diff --git a/FrontEndReact/src/utility.js b/FrontEndReact/src/utility.js index 4c8f3d36d..01f6d32cc 100644 --- a/FrontEndReact/src/utility.js +++ b/FrontEndReact/src/utility.js @@ -1,6 +1,6 @@ import { apiUrl } from './App.js'; import Cookies from 'universal-cookie'; -import { zonedTimeToUtc, format } from "date-fns-tz"; +import { fromZonedTime, format } from "date-fns-tz"; import * as eventsource from "eventsource-client"; export async function genericResourceGET(fetchURL, resource, component, options = {}) { @@ -244,7 +244,7 @@ export function formatDueDate(dueDate, timeZone) { const timeZoneId = timeZoneMap[timeZone]; - const zonedDueDate = zonedTimeToUtc(dueDate, timeZoneId); + const zonedDueDate = fromZonedTime(dueDate, timeZoneId); const formattedDueDate = format(zonedDueDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", { timeZone: timeZoneId });