diff --git a/AWS/maintenance.html b/AWS/maintenance.html new file mode 100644 index 000000000..be5ce6027 --- /dev/null +++ b/AWS/maintenance.html @@ -0,0 +1,63 @@ + + + + + + SkillBuilder - System Maintenance + + + + + + + +
+ +

System Maintenance

+

We're currently performing scheduled maintenance to improve your SkillBuilder experience. Our team is working to bring the system back online as quickly as possible.

+

Thank you for your patience.

+
System Status: Maintenance in Progress
+
+ + \ No newline at end of file diff --git a/BackEndFlask/Functions/customExceptions.py b/BackEndFlask/Functions/customExceptions.py index efe37b7bb..635bb120d 100644 --- a/BackEndFlask/Functions/customExceptions.py +++ b/BackEndFlask/Functions/customExceptions.py @@ -18,14 +18,20 @@ def __str__(self): return self.message class TooManyColumns(Exception): - def __init__(self): - self.message = "Raised when the submitted file has more columns than excepted" + def __init__(self, row_num: int, expected: int, found: int): + self.row_num = row_num + self.expected = expected + self.found = found + self.message = f"Row {row_num} has {found} columns, expected maximum of {expected}" def __str__(self): return self.message class NotEnoughColumns(Exception): - def __init__(self): - self.message = "Raised when the submitted file has less columns than expected" + def __init__(self, row_num: int, expected: int, found: int): + self.row_num = row_num + self.expected = expected + self.found = found + self.message = f"Row {row_num} has {found} columns, expected minimum of {expected}" def __str__(self): return self.message @@ -36,26 +42,8 @@ def __str__(self): return self.message class UserDoesNotExist(Exception): - def __init__(self): - self.message = "Raised when the submitted file has one email not associated to an existing user" - def __str__(self): - return self.message - -class UsersDoNotExist(Exception): - def __init__(self): - self.message = "Raised when the submitted file has more than one email not associated to an existing user" - def __str__(self): - return self.message - -class TANotYetAddedToCourse(Exception): - def __init__(self): - self.message = "Raised when the submitted file has an existing TA who has not been assigned to the course" - def __str__(self): - return self.message - -class StudentNotEnrolledInThisCourse(Exception): - def __init__(self): - self.message = "Raised when the submitted file has a student email associated to an existing student who is not enrolled in the course" + def __init__(self, email): + self.message = f"No user found with email: {email}" def __str__(self): return self.message @@ -78,20 +66,8 @@ def __str__(self): return self.message class InvalidLMSID(Exception): - def __init__(self): - self.message = "Raise when an expected lms_id is not an integer" - def __str__(self): - return self.message - -class OwnerIDDidNotCreateTheCourse(Exception): - def __init__(self): - self.message = "Raised when the specified owner did not create the corresponding course" - def __str__(self): - return self.message - -class CourseDoesNotExist(Exception): - def __init__(self): - self.message = "Raised when course id passed is not a valid course id" + def __init__(self, row_num, lms_id): + self.message = f"Row {row_num}: LMS ID '{lms_id}' must be a positive integer" def __str__(self): return self.message @@ -138,8 +114,69 @@ def __init__(self): def __str__(self): return self.message + +class InvalidNameFormat(Exception): + def __init__(self, row_num, name): + self.message = f"Row {row_num}: Name '{name}' is not in format 'Last Name, First Name'" + def __str__(self): + return self.message + +class InvalidEmail(Exception): + def __init__(self, row_num, email): + self.message = f"Row {row_num}: Invalid email format '{email}'" + def __str__(self): + return self.message + +class DuplicateEmail(Exception): + def __init__(self, email, row_nums): + self.message = f"Email '{email}' appears multiple times in rows: {row_nums}" + def __str__(self): + return self.message + +class DuplicateLMSID(Exception): + def __init__(self, lms_id, row_nums): + self.message = f"LMS ID '{lms_id}' appears multiple times in rows: {row_nums}" + def __str__(self): + return self.message + class InvalidRole(Exception): - def __init__(self): - self.message = "Raised when a role is not valid" + def __init__(self, row_num, role, valid_roles): + self.message = f"Row {row_num}: '{role}' is not a valid role. Valid roles are: {', '.join(valid_roles)}" + def __str__(self): + return self.message + +class UsersDoNotExist(Exception): + def __init__(self, emails): + self.message = f"Multiple users not found with emails: {', '.join(emails)}" def __str__(self): return self.message + +class TANotYetAddedToCourse(Exception): + def __init__(self, email): + self.message = f"TA with email {email} exists but is not assigned to this course" + def __str__(self): + return self.message + +class StudentNotEnrolledInThisCourse(Exception): + def __init__(self, email): + self.message = f"Student with email {email} exists but is not enrolled in this course" + def __str__(self): + return self.message + +class CourseDoesNotExist(Exception): + def __init__(self, course_id): + self.message = f"No course found with ID: {course_id}" + def __str__(self): + return self.message + +class OwnerIDDidNotCreateTheCourse(Exception): + def __init__(self, owner_id, course_id): + self.message = f"User {owner_id} is not the owner of course {course_id}" + def __str__(self): + return self.message + +class EmptyFile(Exception): + def __init__(self): + self.message = "The submitted file is empty" + def __str__(self): + return self.message \ No newline at end of file diff --git a/BackEndFlask/Functions/exportCsv.py b/BackEndFlask/Functions/exportCsv.py index 51f83655d..78a981c12 100644 --- a/BackEndFlask/Functions/exportCsv.py +++ b/BackEndFlask/Functions/exportCsv.py @@ -4,10 +4,8 @@ # and returns to a csv file to a customer. # # NOTE: -# the current way to write out things is as follows: -# AT_name, RN(AT_type, AT_completer), TeamName, IndividualName, CompDate, Category, datapoint -# / \ | -# unitofasess... roleid rating,oc,sfi +# The Template method pattern was used since both exports deal with similar related data fetches +# from the data base but just handle formating differently. This is hopefully expandable. #---------------------------------------------------------------------------------------------------- import csv import io @@ -15,8 +13,7 @@ from models.queries import * from enum import Enum from datetime import datetime - - +from abc import ABC, abstractmethod def rounded_hours_difference(completed: datetime, seen: datetime) -> int: @@ -32,15 +29,21 @@ def rounded_hours_difference(completed: datetime, seen: datetime) -> int: Return: Result: int (The lag_time between completed and seen) + + Exception: + TypeError: Both arguements must be datetimes. """ + + if not isinstance(seen, datetime): raise TypeError(f"Expected: {datetime}, got {type(seen)} for seen.") + if not isinstance (completed, datetime): raise TypeError(f"Expected: {datetime}, got {type(completed)} for completed.") + time_delta = seen - completed hours_remainder = divmod( divmod( time_delta.total_seconds(), 60 )[0], 60) return int(hours_remainder[0]) if hours_remainder[1] < 30.0 else int(hours_remainder[0]) + 1 - -class Csv_data(Enum): +class Csv_Data(Enum): """ Description: Locations associated to where they are in the json file. @@ -59,124 +62,202 @@ class Csv_data(Enum): AT_COMPLETER = 4 - TEAM_NAME = 5 + TEAM_ID = 5 - FIRST_NAME = 6 + TEAM_NAME = 6 - LAST_NAME = 7 + USER_ID = 7 - COMP_DATE = 8 + FIRST_NAME = 8 - LAG_TIME = 9 + LAST_NAME = 9 - NOTIFICATION = 10 + COMP_DATE = 10 - JSON = 11 + LAG_TIME = 11 + + NOTIFICATION = 12 + JSON = 13 -def create_csv(at_id: int) -> str: +class Csv_Creation(ABC): """ - Description: - Creates the csv file and dumps info in to it. - File name follows the convention: [0-9]*.csv + Description: Abstract class that leads to the creation of a csv formated string. + Decided to do this pattern becasue the clients seem to want this same data in many + differing formated outputs. This way all that needs to get created with the same data is + the format rather than working on the query and repeating the same starter code. + """ + + def __init__(self, at_id) -> None: + """ + Parameters: + at_id: + """ + self._at_id = at_id + self._csv_file = io.StringIO() + self._writer = csv.writer(self._csv_file, delimiter='\t') + self._completed_assessment_data = None + self._oc_sfi_data = None + self._is_teams = False + self._singular_data = None + + def return_csv_str(self) -> str: + """ + Description: Returns a csv formated string. + + Return: + + """ + + # Writting a common identifying data. + self._writer.writerow(['\ufeff']) # A dom that helps excel auto use utf-8. Downside is that it uses up a line. + self._writer.writerow(["Course Name"]) + self._writer.writerow([get_course_name_by_at_id(self._at_id)]) + self._writer.writerow([' ']) + + + # List of dicts: Each list is another individual in the AT and the dict is there related data. + self._completed_assessment_data = get_csv_data_by_at_id(self._at_id) + + if len(self._completed_assessment_data) == 0: + return self._csv_file.getvalue() + + self._singular = self._completed_assessment_data[0] + self._is_teams = False if self._singular[Csv_Data.TEAM_NAME.value] == None else True + + self._format() + + return self._csv_file.getvalue() + + def __del__(self) -> None: + """ + Description: Freeing resources. + """ + self._csv_file.close() + + @abstractmethod + def _format(self) -> None: + pass + +class Ratings_Csv(Csv_Creation): + """ + Description: Singleton that creates a csv string of ratings. + """ + def __init__(self, at_id:int) -> None: + """ + Parameters: + at_id: + """ + super().__init__(at_id) + + def _format(self) -> None: + """ + Description: Formats the data in the csv string. + Exceptions: None except what IO can rise. + """ + column_name = ["First Name"] + ["Last Name"] if not self._is_teams else ["Team Name"] + + # Adding the column name. Noitice that done and comments is skipped since they are categories but are not important. + column_name += [i for i in self._singular[Csv_Data.JSON.value] if (i != "done" and i !="comments")] + + column_name += ["Lag Time"] + + self._writer.writerow(column_name) + + row_info = None + + # Notice that in the list comphrehensions done and comments are skiped since they are categories but dont hold relavent data. + for individual in self._completed_assessment_data: + + row_info = [individual[Csv_Data.FIRST_NAME.value]] + [individual[Csv_Data.LAST_NAME.value]] if not self._is_teams else [individual[Csv_Data.TEAM_NAME.value]] + + row_info += [individual[Csv_Data.JSON.value][category]["rating"] for category in individual[Csv_Data.JSON.value] if (category != "done" and category !="comments")] + + lag = [" "] + try: + # Possible that a particular individual has not yet seen so its a Nonetype in the backend. + lag = [rounded_hours_difference(individual[Csv_Data.COMP_DATE.value], individual[Csv_Data.LAG_TIME.value])] + except TypeError: + pass + + row_info += lag + self._writer.writerow(row_info) + +class Ocs_Sfis_Csv(Csv_Creation): + """ + Description: Singleton that creates a csv string of ratings. + """ + def __init__(self, at_id: int) -> None: + """ + Parameters: + at_id: + """ + super().__init__(at_id) + self.__checkmark = '\u2713' + self.__crossmark = " " + + def _format(self) -> None: + """ + Description: Formats the data in the csv string. + Exceptions: None except what IO can rise. + """ + # Writing out in category chuncks. + for category in self._singular[Csv_Data.JSON.value]: + if category == "done" or category == "comments": # Yes those two are "categories" at least from how the data is pulled. + continue + + headers = ["First Name"] + ["Last Name"] if not self._is_teams else ["Team Name"] + + oc_sfi_per_category = get_csv_categories(self._singular[Csv_Data.RUBRIC_ID.value], + self._singular[Csv_Data.USER_ID.value], + self._singular[Csv_Data.TEAM_ID.value], + self._at_id, category) + + # Adding the other column names which are the ocs and sfi text. + headers += ["OC:" + i[0] for i in oc_sfi_per_category[0]] + ["SFI:" + i[0] for i in oc_sfi_per_category[1]] + + self._writer.writerow([category]) + self._writer.writerow(headers) + + # Writing the checkmarks. + for individual in self._completed_assessment_data: + respective_ocs_sfis = [individual[Csv_Data.JSON.value][category]["observable_characteristics"], + individual[Csv_Data.JSON.value][category]["suggestions"]] + + row = None + if not self._is_teams: row = [individual[Csv_Data.FIRST_NAME.value]] + [individual[Csv_Data.LAST_NAME.value]] + else: row = [individual[Csv_Data.TEAM_NAME.value]] + + for bits in respective_ocs_sfis: + row += [self.__checkmark if i == "1" else self.__crossmark for i in bits] + + self._writer.writerow(row) + self._writer.writerow(['']) + +class CSV_Type(Enum): + """ + Description: This is the enum for the different types of csv file formats the clients have requested. + """ + RATING_CSV = 0 + OCS_SFI_CSV = 1 + +def create_csv_strings(at_id:int, type_csv=CSV_Type.OCS_SFI_CSV.value) -> str: + """ + Description: Creates a csv file with the data in the format specified by type_csv. Parameters: - at_id: int (The id of an assessment task) + at_id: (Desired assessment task) + type_csv: (Desired format) + + Returns: + - Return: - str + Exceptions: None except the chance the database or IO calls raise one. """ - # Assessment_task_name, Completion_date, Rubric_name, AT_type (Team/individual), AT_completer_role (Admin, TA/Instructor, Student), Notification_date - with app.app_context(): - with io.StringIO() as csvFile: - writer = csv.writer(csvFile, quoting=csv.QUOTE_MINIMAL) - - # Next line is the header line and its values. - writer.writerow( - ["Assessment_task_name"] + - ["Completion_date"]+ - ["Rubric_name"]+ - ["AT_type (Team/individual)"] + - ["AT_completer_role (Admin[TA/Instructor] / Student)"] + - ["Notification_data"] - ) - - completed_assessment_data = get_csv_data_by_at_id(at_id) - - if len(completed_assessment_data) == 0: - return csvFile.getvalue() - - writer.writerow( - [completed_assessment_data[0][Csv_data.AT_NAME.value]] + - [completed_assessment_data[0][Csv_data.COMP_DATE.value]] + - [completed_assessment_data[0][Csv_data.RUBRIC_NAME.value]] + - ["Team" if completed_assessment_data[0][Csv_data.AT_TYPE.value] else "Individual"] + - [completed_assessment_data[0][Csv_data.AT_COMPLETER.value]] + - [completed_assessment_data[0][Csv_data.NOTIFICATION.value]] - ) - - # The block generates data lines. - writer.writerow( - ["Team_name"] + - ["First name"] + - ["last name"] + - ["Category"] + - ["Rating"] + - ["Observable Characteristics"] + - ["Suggestions for Improvement"] + - ["feedback time lag"] - ) - - for entry in completed_assessment_data: - sfi_oc_data = get_csv_categories(entry[Csv_data.RUBRIC_ID.value]) - - lag = "" - - try: - lag = rounded_hours_difference(entry[Csv_data.COMP_DATE.value], entry[Csv_data.LAG_TIME.value]) - except: - pass - - for i in entry[Csv_data.JSON.value]: - if i == "comments" or i == "done": - continue - - oc = entry[Csv_data.JSON.value][i]["observable_characteristics"] - - for j in range (0, len(oc)): - if(oc[j] == '0'): - continue - - writer.writerow( - [entry[Csv_data.TEAM_NAME.value]] + - [entry[Csv_data.FIRST_NAME.value]] + - [entry[Csv_data.LAST_NAME.value]] + - [i] + - [entry[Csv_data.JSON.value][i]["rating"]] + - [sfi_oc_data[1][j][1]] + - [""] + - [lag] - ) - - for i in entry[Csv_data.JSON.value]: - if i == "comments" or i == "done": - continue - - sfi = entry[Csv_data.JSON.value][i]["suggestions"] - - for j in range (0, len(sfi)): - if(sfi[j] == '0'): - continue - - writer.writerow( - [entry[Csv_data.TEAM_NAME.value]] + - [entry[Csv_data.FIRST_NAME.value]] + - [entry[Csv_data.LAST_NAME.value]] + - [i] + - [entry[Csv_data.JSON.value][i]["rating"]] + - [""] + - [sfi_oc_data[0][j][1]] + - [lag] - ) - - return csvFile.getvalue() + match type_csv: + case CSV_Type.RATING_CSV.value: + return Ratings_Csv(at_id).return_csv_str() + case CSV_Type.OCS_SFI_CSV.value: + return Ocs_Sfis_Csv(at_id).return_csv_str() + case _: + return "No current class meets the deisred csv format. Error in create_csv_strings()." \ No newline at end of file diff --git a/BackEndFlask/Functions/genericImport.py b/BackEndFlask/Functions/genericImport.py index 71d6d852d..c80c79d5d 100644 --- a/BackEndFlask/Functions/genericImport.py +++ b/BackEndFlask/Functions/genericImport.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Dict, Tuple as TypingTuple from core import db from Functions.test_files.PopulationFunctions import * @@ -11,6 +11,53 @@ import itertools import csv +def validate_row(row: List[str], row_num: int, seen_emails: Dict[str, int], + seen_lms_ids: Dict[str, int], valid_roles: List[str]) -> TypingTuple[str, str, str, str, str]: + """Validates a single row of CSV data and returns parsed values""" + + # Check column count + if len(row) < 3: + raise NotEnoughColumns(row_num, 3, len(row)) + if len(row) > 4: + raise TooManyColumns(row_num, 4, len(row)) + + # Validate and parse name + name = row[0].strip() + if ',' not in name: + raise InvalidNameFormat(row_num, name) + + last_name = name.split(',')[0].strip() + first_name = name.split(',')[1].strip() + + if not last_name or not first_name: + raise InvalidNameFormat(row_num, name) + + # Validate email + email = row[1].strip() + if not helper_verify_email_syntax(email): + raise InvalidEmail(row_num, email) + + if email in seen_emails: + raise DuplicateEmail(email, [seen_emails[email], row_num]) + seen_emails[email] = row_num + + # Validate role + role = row[2].strip() + if role not in valid_roles: + raise InvalidRole(row_num, role, valid_roles) + + # Validate optional LMS ID + lms_id = None + if len(row) == 4 and row[3].strip(): + lms_id = row[3].strip() + if not lms_id.isdigit(): + raise InvalidLMSID(row_num, lms_id) + if lms_id in seen_lms_ids: + raise DuplicateLMSID(lms_id, [seen_lms_ids[lms_id], row_num]) + seen_lms_ids[lms_id] = row_num + + return first_name, last_name, email, role, lms_id + def __add_user(owner_id, course_id, first_name, last_name, email, role_id, lms_id): """ Description @@ -117,15 +164,23 @@ def generic_csv_to_db(user_file: str, owner_id: int, course_id: int) -> None|str # Renamed `reader` -> `roster`. roster: list[list[str]] = list(itertools.tee(csv.reader(student_csv))[0]) + if not roster: + raise EmptyFile() + # For keeping students in a "queue" as we are parsing # the file. During parsing, we add the relevant information # to this list (first_name, last_name, email, role_id, lms_id). students: list[tuple] = [] + + # Track duplicate checks + seen_emails: dict[str, int] = {} + seen_lms_ids: dict[str, int] = {} + valid_roles = ["Student", "TA", "Instructor"] for row in range(0, len(roster)): person_attribs: list[str] = roster[row] - # Skip all newlines for convenience + # Skip empty rows if len(person_attribs) == 0: continue @@ -134,37 +189,54 @@ def generic_csv_to_db(user_file: str, owner_id: int, course_id: int) -> None|str MAX_PERSON_ATTRIBS_COUNT: int = 4 # Checking for 4 for: FN LN, email, role, (optional) LMS ID if len(person_attribs) < MIN_PERSON_ATTRIBS_COUNT: - raise NotEnoughColumns + raise NotEnoughColumns(row + 1, MIN_PERSON_ATTRIBS_COUNT, len(person_attribs)) if len(person_attribs) > MAX_PERSON_ATTRIBS_COUNT: - raise TooManyColumns + raise TooManyColumns(row + 1, MAX_PERSON_ATTRIBS_COUNT, len(person_attribs)) name: str = person_attribs[0].strip() # FN,LN - last_name: str = name.replace(",", "").split()[0].strip() + # Validate name format + if ',' not in name: + raise InvalidNameFormat(row + 1, name) - first_name: str = name.replace(",", "").split()[1].strip() + last_name: str = name.split(',')[0].strip() + first_name: str = name.split(',')[1].strip() + + if not last_name or not first_name: + raise InvalidNameFormat(row + 1, name) email: str = person_attribs[1].strip() + # Check for duplicate emails + if email in seen_emails: + raise DuplicateEmail(email, [seen_emails[email], row + 1]) + seen_emails[email] = row + 1 + role: str = person_attribs[2].strip() + # Validate role before conversion + if role not in valid_roles: + raise InvalidRole(row + 1, role, valid_roles) + lms_id: int|None = None role = helper_str_to_int_role(role) - - # Corresponding role ID for the string `role`. - # TODO: returns tuple, check for the ID attr, or the name. role = get_role(role) - role_id = role.role_id - # If the len of `header` == 4, then the LMS ID is present. + # If the len of person_attribs == 4, then the LMS ID is present. if len(person_attribs) == 4: lms_id = person_attribs[3].strip() + if lms_id: # Only validate if not empty + if not lms_id.isdigit(): + raise InvalidLMSID(row + 1, lms_id) + if lms_id in seen_lms_ids: + raise DuplicateLMSID(lms_id, [seen_lms_ids[lms_id], row + 1]) + seen_lms_ids[lms_id] = row + 1 if not helper_verify_email_syntax(email): - raise SuspectedMisformatting + raise InvalidEmail(row + 1, email) students.append((first_name, last_name, email, role_id, lms_id)) @@ -184,4 +256,4 @@ def generic_csv_to_db(user_file: str, owner_id: int, course_id: int) -> None|str if is_xlsx is not None: delete_xlsx(user_file, is_xlsx) - raise e + raise e \ No newline at end of file diff --git a/BackEndFlask/Functions/studentImport.py b/BackEndFlask/Functions/studentImport.py index c540d33e6..79cfee21f 100644 --- a/BackEndFlask/Functions/studentImport.py +++ b/BackEndFlask/Functions/studentImport.py @@ -5,6 +5,48 @@ from sqlalchemy import * import itertools import csv +from typing import List, Dict, Tuple as TypingTuple + +def validate_student_row(row: List[str], row_num: int, seen_emails: Dict[str, int], + seen_lms_ids: Dict[str, int]) -> TypingTuple[str, str, str, str]: + """Validates a single row of student CSV data and returns parsed values""" + + # Check column count + if len(row) < 3: + raise NotEnoughColumns(row_num, 3, len(row)) + if len(row) > 3: + raise TooManyColumns(row_num, 3, len(row)) + + # Validate and parse name + name = row[0].strip() + if ',' not in name: + raise InvalidNameFormat(row_num, name) + + last_name = name.split(',')[0].strip() + first_name = name.split(',')[1].strip() + + if not last_name or not first_name: + raise InvalidNameFormat(row_num, name) + + # Validate LMS ID (required for students) + lms_id = row[1].strip() + if not lms_id or not lms_id.isdigit(): + raise InvalidLMSID(row_num, lms_id) + + if lms_id in seen_lms_ids: + raise DuplicateLMSID(lms_id, [seen_lms_ids[lms_id], row_num]) + seen_lms_ids[lms_id] = row_num + + # Validate email + email = row[2].strip() + if not is_valid_email(email): + raise InvalidEmail(row_num, email) + + if email in seen_emails: + raise DuplicateEmail(email, [seen_emails[email], row_num]) + seen_emails[email] = row_num + + return first_name, last_name, lms_id, email # student_csv_to_db() # - takes three parameters: @@ -27,24 +69,56 @@ def student_csv_to_db(student_file, owner_id, course_id): student_file = xlsx_to_csv(student_file) try: with open(student_file, mode='r', encoding='utf-8-sig') as studentcsv: - reader = list(itertools.tee(csv.reader(studentcsv))[0]) - header = reader[0] + # Renamed `reader` -> `roster`. + roster: list[list[str]] = list(itertools.tee(csv.reader(studentcsv))[0]) - if len(header) < 3: - raise NotEnoughColumns - if len(header) > 3: - delete_xlsx(student_file, is_xlsx) - raise TooManyColumns + if not roster: + raise EmptyFile() - for row in range(0, len(reader)): - student_name = reader[row][0].strip() - lms_id = reader[row][1].strip() - student_email = reader[row][2].strip() - last_name = student_name.replace(",", "").split()[0].strip() - first_name = student_name.replace(",", "").split()[1].strip() + # Track duplicate checks + seen_emails = {} + seen_lms_ids = {} + + for row in range(0, len(roster)): + # Skip empty rows + if not roster[row]: + continue + + if len(roster[row]) < 3: + raise NotEnoughColumns(row + 1, 3, len(roster[row])) + if len(roster[row]) > 3: + delete_xlsx(student_file, is_xlsx) + raise TooManyColumns(row + 1, 3, len(roster[row])) + + student_name = roster[row][0].strip() + lms_id = roster[row][1].strip() + student_email = roster[row][2].strip() + + # Validate name format + if ',' not in student_name: + raise InvalidNameFormat(row + 1, student_name) + + last_name = student_name.split(',')[0].strip() + first_name = student_name.split(',')[1].strip() + + if not last_name or not first_name: + raise InvalidNameFormat(row + 1, student_name) + + # Validate LMS ID + if not lms_id or not lms_id.isdigit(): + raise InvalidLMSID(row + 1, lms_id) + + if lms_id in seen_lms_ids: + raise DuplicateLMSID(lms_id, [seen_lms_ids[lms_id], row + 1]) + seen_lms_ids[lms_id] = row + 1 + + # Validate email + if not is_valid_email(student_email): + raise InvalidEmail(row + 1, student_email) - if not lms_id.isdigit() or not is_valid_email(student_email): - raise SuspectedMisformatting + if student_email in seen_emails: + raise DuplicateEmail(student_email, [seen_emails[student_email], row + 1]) + seen_emails[student_email] = row + 1 user = get_user_by_email( student_email diff --git a/BackEndFlask/Functions/teamBulkUpload.py b/BackEndFlask/Functions/teamBulkUpload.py index 6aefcaad7..cf93356c7 100755 --- a/BackEndFlask/Functions/teamBulkUpload.py +++ b/BackEndFlask/Functions/teamBulkUpload.py @@ -33,84 +33,86 @@ def __expect(lst: list[list[str]], cols: int | None = None) -> list[str]: matches the expected `cols`. It will then pop off that head element and return it back. This, in turn, will modify the original list passed. - - Parameters: - lst: list[list[str]]: The list of strings that it takes from. - cols: int|None: The number of expected columns. None if we are - not expecting anything. - - Returns: - list[str]: The head of the `lst` variable. """ hd: list[str] = lst.pop(0) - # If the last column in a row is empty, remove it - if hd[-1] == "Unnamed: 1" or hd[-1] == '': - hd.pop() - if cols is not None and len(hd) != cols: - assert False, f'len(list[list[str]])[0] does not match cols expected. Namely: {len(hd)} =/= {cols}' - return hd - + + # 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 def __parse(lst: list[list[str]]) -> list[TBUTeam]: teams: list[TBUTeam] = [] students: list[TBUStudent] = [] ta: str = "" team_name: str = "" - newline: bool = True - - while True: - if len(lst) == 0: - break - + current_row = 0 + + # State to track what type of row we expect next + EXPECT_TA = 0 + EXPECT_TEAM = 1 + EXPECT_STUDENT = 2 + current_state = EXPECT_TA + + while len(lst) > 0: hd = __expect(lst) - - # Decide on what to do base on the num of columns. - match len(hd): - # Newline, add current info to a team. - case 0: - if len(students) == 0: - raise EmptyTeamMembers - if ta == "": - raise EmptyTAEmail - if team_name == "": - raise EmptyTeamName - + current_row += 1 + + # Skip empty rows, they signal end of current team + if not hd: + if len(students) > 0: + if ta == "" or team_name == "": + raise EmptyTeamName if team_name == "" else EmptyTAEmail teams.append(TBUTeam(team_name, ta, students)) students = [] - newline = True - - # Either TA email or a team name. - case 1: - # TA email (because of newline) - if newline: - newline = False - ta = hd[0] - hd = __expect(lst, 1) # Expect a team name. - team_name = hd[0] - - # Team name, use the previous TA email for the next team. - else: - teams.append(TBUTeam(team_name, ta, students)) - students = [] - team_name = hd[0] - - # Student with either an LMS ID or not. - case 2 | 3: + current_state = EXPECT_TA + continue + + # Process based on what type of row we're expecting + if current_state == EXPECT_TA: + if len(hd) != 1: + raise TooManyColumns(current_row, 1, len(hd)) + ta = hd[0] + current_state = EXPECT_TEAM + + elif current_state == EXPECT_TEAM: + if len(hd) != 1: + raise TooManyColumns(current_row, 1, len(hd)) + team_name = hd[0] + current_state = EXPECT_STUDENT + + elif current_state == EXPECT_STUDENT: + # Student row should have 2 or 3 columns + if len(hd) > 3: + raise TooManyColumns(current_row, 3, len(hd)) + elif len(hd) < 2: + raise NotEnoughColumns(current_row, 2, len(hd)) + + try: lname, fname = hd[0].split(',') fname = fname.strip() lname = lname.strip() email = hd[1] lms_id = None if len(hd) == 2 else hd[2] students.append(TBUStudent(fname, lname, email, lms_id)) + except ValueError: + raise InvalidNameFormat(current_row, hd[0]) - # Too many columns expected. - case _: - raise TooManyColumns - - # If there is no newline at EOF... + # Handle the last team if there are students if len(students) > 0: + if ta == "" or team_name == "": + raise EmptyTeamName if team_name == "" else EmptyTAEmail teams.append(TBUTeam(team_name, ta, students)) + if len(teams) == 0: + raise EmptyTeamMembers + return teams @@ -178,7 +180,7 @@ def __handle_ta(): if course_uses_tas: ta = get_user_by_email(ta_email) if ta is None: - raise UserDoesNotExist + raise UserDoesNotExist(ta_email) missing_ta = False ta_id = get_user_user_id_by_email(ta_email) @@ -188,7 +190,7 @@ def __handle_ta(): else: user = get_user(owner_id) if user is None: - raise UserDoesNotExist + raise UserDoesNotExist(owner_id) course = get_course(course_id) courses = get_courses_by_admin_id(owner_id) @@ -200,7 +202,7 @@ def __handle_ta(): break if not course_found: - raise OwnerIDDidNotCreateTheCourse + raise OwnerIDDidNotCreateTheCourse(owner_id, course_id) return (ta_id, missing_ta, course_uses_tas) diff --git a/BackEndFlask/controller/Routes/Assessment_task_routes.py b/BackEndFlask/controller/Routes/Assessment_task_routes.py index f1ecfd32a..814b6a313 100644 --- a/BackEndFlask/controller/Routes/Assessment_task_routes.py +++ b/BackEndFlask/controller/Routes/Assessment_task_routes.py @@ -8,8 +8,12 @@ from models.role import get_role from controller.Route_response import * from models.user_course import get_user_courses_by_user_id + from flask_jwt_extended import jwt_required -from controller.security.CustomDecorators import AuthCheck, bad_token_check +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) from models.assessment_task import ( get_assessment_tasks_by_course_id, @@ -173,6 +177,7 @@ def get_one_assessment_task(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def add_assessment_task(): try: new_assessment_task = create_assessment_task(request.json) @@ -192,6 +197,7 @@ def add_assessment_task(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_assessment_task(): try: if request.args and request.args.get("notification"): @@ -256,6 +262,7 @@ def update_assessment_task(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def copy_course_assessments(): try: source_course_id = request.args.get('source_course_id') @@ -307,6 +314,7 @@ class Meta: "create_team_password", "comment", "number_of_teams", + "max_team_size", "notification_sent" ) diff --git a/BackEndFlask/controller/Routes/Bulk_upload_routes.py b/BackEndFlask/controller/Routes/Bulk_upload_routes.py index 87830173b..1e2eb64fc 100644 --- a/BackEndFlask/controller/Routes/Bulk_upload_routes.py +++ b/BackEndFlask/controller/Routes/Bulk_upload_routes.py @@ -9,8 +9,18 @@ import os import shutil from controller.Route_response import create_bad_response, create_good_response +from flask_jwt_extended import jwt_required + +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) @bp.route('/bulk_upload', methods = ['POST']) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() def upload_CSV(): try: file = request.files['csv_file'] diff --git a/BackEndFlask/controller/Routes/Checkin_routes.py b/BackEndFlask/controller/Routes/Checkin_routes.py index 0576d2865..e5b5a2ada 100644 --- a/BackEndFlask/controller/Routes/Checkin_routes.py +++ b/BackEndFlask/controller/Routes/Checkin_routes.py @@ -1,14 +1,24 @@ +import json from flask import request +import flask +from flask_jwt_extended import jwt_required +from controller.security.CustomDecorators import AuthCheck, bad_token_check from models.checkin import * from controller import bp from controller.Route_response import * +from core import red, app from models.queries import ( get_all_checkins_for_assessment, get_all_checkins_for_student_for_course ) +CHECK_IN_REDIS_CHANNEL = "check_in_updated" + @bp.route('/checkin', methods = ['POST']) +@jwt_required() +@bad_token_check() +@AuthCheck() def checkin_user(): # needs json with AT id, user id, and team id try: @@ -24,6 +34,8 @@ def checkin_user(): update_checkin(new_checkin) else: create_checkin(new_checkin) + + red.publish(CHECK_IN_REDIS_CHANNEL, assessment_task_id) return create_good_response(new_checkin, 200, "checkin") @@ -31,6 +43,9 @@ def checkin_user(): return create_bad_response(f"An error occurred checking in user: {e}", "checkin", 400) @bp.route('/checkin', methods = ['GET']) +@jwt_required() +@bad_token_check() +@AuthCheck() def get_checked_in(): # given an asessment task id, return checked in information try: @@ -52,6 +67,36 @@ def get_checked_in(): except Exception as e: return create_bad_response(f"An error occurred getting checked in user {e}", "checkin", 400) +@bp.route('/checkin_events', methods = ['GET']) +@jwt_required() +@bad_token_check() +@AuthCheck() +def stream_checked_in_events(): + try: + assessment_task_id = int(request.args.get("assessment_task_id")) + + def encode_message(): + with app.app_context(): + checkins = get_all_checkins_for_assessment(assessment_task_id) + checkins_json = json.dumps(checkins_schema.dump(checkins)) + + return f"data: {checkins_json}\n\n" + + def check_in_stream(): + with red.pubsub() as pubsub: + pubsub.subscribe(CHECK_IN_REDIS_CHANNEL) + + yield encode_message() + + for msg in pubsub.listen(): + if msg["type"] == "message" and str(msg["data"]) == str(assessment_task_id): + yield encode_message() + + return flask.Response(check_in_stream(), mimetype="text/event-stream", status=200) + + except Exception as e: + return create_bad_response(f"An error occurred getting checked in user {e}", "checkin", 400) + class CheckinSchema(ma.Schema): class Meta: fields = ( diff --git a/BackEndFlask/controller/Routes/Completed_assessment_routes.py b/BackEndFlask/controller/Routes/Completed_assessment_routes.py index 85729cdbb..7013c362d 100644 --- a/BackEndFlask/controller/Routes/Completed_assessment_routes.py +++ b/BackEndFlask/controller/Routes/Completed_assessment_routes.py @@ -3,51 +3,88 @@ from controller.Route_response import * from flask_jwt_extended import jwt_required from models.assessment_task import get_assessment_task -from controller.security.CustomDecorators import AuthCheck, bad_token_check + +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) from models.completed_assessment import ( get_completed_assessments, get_completed_assessment_by_course_id, create_completed_assessment, replace_completed_assessment, - completed_assessment_exists + completed_assessment_exists, + get_completed_assessment_count ) from models.queries import ( get_completed_assessment_by_ta_user_id, get_completed_assessment_with_team_name, get_completed_assessment_by_user_id, - get_completed_assessment_with_user_name + get_completed_assessment_with_user_name, + get_completed_assessment_ratio, ) +from models.assessment_task import get_assessment_tasks_by_course_id -@bp.route('/completed_assessment', methods = ['GET']) +@bp.route('/completed_assessment', methods=['GET']) @jwt_required() @bad_token_check() @AuthCheck() def get_all_completed_assessments(): try: + # only_course is a marker parameter that prevents requests intended for routes + # below from hitting this route + if request.args and request.args.get("course_id") and request.args.get("only_course") == "true": + course_id = int(request.args.get("course_id")) + all_completed_assessments = get_completed_assessment_by_course_id(course_id) + assessment_tasks = get_assessment_tasks_by_course_id(course_id) + + result = [] + for task in assessment_tasks: + completed_count = get_completed_assessment_count(task.assessment_task_id) + completed_assessments = [ca for ca in all_completed_assessments if ca.assessment_task_id == task.assessment_task_id] + + result.append({ + 'assessment_task_id': task.assessment_task_id, + 'assessment_task_name': task.assessment_task_name, + 'completed_count': completed_count, + 'unit_of_assessment': task.unit_of_assessment, + 'completed_assessments': completed_assessment_schemas.dump(completed_assessments) if completed_assessments else [] + }) + + return create_good_response(result, 200, "completed_assessments") + if request.args and request.args.get("course_id") and request.args.get("role_id"): # if the args have a role id, then it is a TA so it should return their completed assessments course_id = int(request.args.get("course_id")) - user_id = request.args.get("user_id") + user_id = request.args.get("user_id") completed_assessments_task_by_user = get_completed_assessment_by_ta_user_id(course_id, user_id) return create_good_response(completed_assessment_schemas.dump(completed_assessments_task_by_user), 200, "completed_assessments") if request.args and request.args.get("course_id") and request.args.get("user_id"): + if request.args.get("assessment_id"): + course_id = request.args.get("course_id") + + assessment_id = request.args.get("assessment_id") - course_id = int(request.args.get("course_id")) + ratio = get_completed_assessment_ratio(course_id, assessment_id) - user_id = request.args.get("user_id") + return create_good_response(ratio, 200, "completed_assessments") + else: + course_id = int(request.args.get("course_id")) - completed_assessments_task_by_user = get_completed_assessment_by_user_id(course_id, user_id) + user_id = request.args.get("user_id") - return create_good_response(completed_assessment_schemas.dump(completed_assessments_task_by_user), 200, "completed_assessments") + completed_assessments_task_by_user = get_completed_assessment_by_user_id(course_id, user_id) + + return create_good_response(completed_assessment_schemas.dump(completed_assessments_task_by_user), 200, "completed_assessments") if request.args and request.args.get("assessment_task_id") and request.args.get("unit"): assessment_task_id = int(request.args.get("assessment_task_id")) @@ -56,42 +93,60 @@ def get_all_completed_assessments(): get_assessment_task(assessment_task_id) # Trigger an error if not exists. if (unit == "team"): - completed_assessments_by_assessment_task_id = get_completed_assessment_with_team_name(assessment_task_id) + completed_assessments = get_completed_assessment_with_team_name(assessment_task_id) else: - completed_assessments_by_assessment_task_id = get_completed_assessment_with_user_name(assessment_task_id) - - return create_good_response(completed_assessment_schemas.dump(completed_assessments_by_assessment_task_id), 200, "completed_assessments") + completed_assessments = get_completed_assessment_with_user_name(assessment_task_id) + + completed_count = get_completed_assessment_count(assessment_task_id) + result = [ + {**completed_assessment_schema.dump(assessment), 'completed_count': completed_count} + for assessment in completed_assessments + ] + return create_good_response(result, 200, "completed_assessments") if request.args and request.args.get("assessment_task_id"): assessment_task_id = int(request.args.get("assessment_task_id")) - + get_assessment_task(assessment_task_id) # Trigger an error if not exists. - - completed_assessments_by_assessment_task_id = get_completed_assessment_with_team_name(assessment_task_id) - - return create_good_response(completed_assessment_schemas.dump(completed_assessments_by_assessment_task_id), 200, "completed_assessments") - - if request.args and request.args.get("course_id"): - course_id = int(request.args.get("course_id")) - - all_completed_assessments = get_completed_assessment_by_course_id(course_id) - - return create_good_response(completed_assessment_schemas.dump(all_completed_assessments), 200, "completed_assessments") + completed_assessments = get_completed_assessment_with_team_name(assessment_task_id) + completed_count = get_completed_assessment_count(assessment_task_id) + result = [ + {**completed_assessment_schema.dump(assessment), 'completed_count': completed_count} + for assessment in completed_assessments + ] + return create_good_response(result, 200, "completed_assessments") if request.args and request.args.get("completed_assessment_task_id"): completed_assessment_task_id = int(request.args.get("completed_assessment_task_id")) - one_completed_assessment = get_completed_assessment_with_team_name(completed_assessment_task_id) - return create_good_response(completed_assessment_schema.dump(one_completed_assessment), 200, "completed_assessments") - all_completed_assessments=get_completed_assessments() - + all_completed_assessments = get_completed_assessments() return create_good_response(completed_assessment_schemas.dump(all_completed_assessments), 200, "completed_assessments") except Exception as e: return create_bad_response(f"An error occurred retrieving all completed assessments: {e}", "completed_assessments", 400) +@bp.route('/completed_assessment', methods = ['GET']) +@jwt_required() +@bad_token_check() +@AuthCheck() +def get_completed_assessment_by_team_or_user_id(): + try: + completed_assessment_id = request.args.get("completed_assessment_id") + unit = request.args.get("unit") + if not completed_assessment_id: + return create_bad_response("No completed_assessment_id provided", "completed_assessments", 400) + + if unit == "team": + one_completed_assessment = get_completed_assessment_with_team_name(completed_assessment_id) + elif unit == "user": + one_completed_assessment = get_completed_assessment_with_user_name(completed_assessment_id) + else: + create_bad_response("Invalid unit provided", "completed_assessments", 400) + return create_good_response(completed_assessment_schema.dump(one_completed_assessment), 200, "completed_assessments") + except Exception as e: + return create_bad_response(f"An error occurred fetching a completed assessment: {e}", "completed_assessments", 400) @bp.route('/completed_assessment', methods = ['POST']) @jwt_required() @@ -122,6 +177,7 @@ def add_completed_assessment(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_completed_assessment(): try: completed_assessment_id = request.args.get("completed_assessment_id") @@ -157,7 +213,8 @@ class Meta: 'last_update', 'rating_observable_characteristics_suggestions_data', 'course_id', - 'rubric_id' + 'rubric_id', + 'completed_count' ) diff --git a/BackEndFlask/controller/Routes/Course_routes.py b/BackEndFlask/controller/Routes/Course_routes.py index 5493963e5..9c5d57a10 100644 --- a/BackEndFlask/controller/Routes/Course_routes.py +++ b/BackEndFlask/controller/Routes/Course_routes.py @@ -2,7 +2,11 @@ from controller import bp from controller.Route_response import * from flask_jwt_extended import jwt_required -from controller.security.CustomDecorators import AuthCheck, bad_token_check + +from controller.security.CustomDecorators import( + AuthCheck, bad_token_check, + admin_check +) from models.course import( get_course, @@ -57,6 +61,9 @@ def get_all_courses(): @bp.route('/course', methods=['GET']) +@jwt_required() +@bad_token_check() +@AuthCheck() def get_one_course(): try: course_id = int(request.args.get("course_id")) @@ -73,6 +80,7 @@ def get_one_course(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def add_course(): try: new_course = create_course(request.json) @@ -95,6 +103,7 @@ def add_course(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_course(): try: course_id = request.args.get("course_id") diff --git a/BackEndFlask/controller/Routes/Csv_routes.py b/BackEndFlask/controller/Routes/Csv_routes.py index 67b726147..6b6058822 100644 --- a/BackEndFlask/controller/Routes/Csv_routes.py +++ b/BackEndFlask/controller/Routes/Csv_routes.py @@ -1,55 +1,53 @@ #---------------------------------------------------------------------------------------------------- # Developer: Aldo Vera-Espinoza -# Date: 8 May, 2024 +# Date: 14 November, 2024 # File Purpose: -# Creates a way for the front end to ask for a csv file and get a properly filled -# csv sent back. +# Functions to create a csv file for (ocs and sfis) and ratings for a given assessment task. #---------------------------------------------------------------------------------------------------- from flask import request from controller import bp from controller.Route_response import * from flask_jwt_extended import jwt_required -from controller.security.CustomDecorators import AuthCheck, bad_token_check -from Functions.exportCsv import create_csv +from Functions.exportCsv import create_csv_strings from models.assessment_task import get_assessment_task from models.user import get_user - +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) @bp.route('/csv_assessment_export', methods = ['GET']) @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def get_completed_assessment_csv() -> dict: """ Description: - Creates a csv that has the following info - in this order respectively. + Creates a csv according to the desired format. Parameter: - assessment_task_id: int + assessment_task_id: int (desired at_id) + format: int (desired data and format for the csv) Return: Response dictionary and possibly the file. """ try: assessment_task_id = request.args.get("assessment_task_id") + format = request.args.get("format") + + if format == None: raise ValueError("Format should be an int.") + format = int(format) - assessment = get_assessment_task(assessment_task_id) # Trigger an error if not exists + get_assessment_task(assessment_task_id) # Trigger an error if not exists user_id = request.args.get("user_id") - user = get_user(user_id) # Trigger an error if not exist - - file_name = user.first_name + "_" - - file_name += user.last_name + "_" - - file_name += assessment.assessment_task_name.replace(" ", "_") + ".csv" + get_user(user_id) # Trigger an error if not exist - csv_data = create_csv( - assessment_task_id - ) + csv_data = create_csv_strings(assessment_task_id, format) return create_good_response({ "csv_data": csv_data.strip() }, 200, "csv_creation") diff --git a/BackEndFlask/controller/Routes/Feedback_routes.py b/BackEndFlask/controller/Routes/Feedback_routes.py index 6afe65a54..6d67c7471 100644 --- a/BackEndFlask/controller/Routes/Feedback_routes.py +++ b/BackEndFlask/controller/Routes/Feedback_routes.py @@ -2,16 +2,21 @@ from controller import bp from controller.Route_response import * from datetime import datetime +from flask_jwt_extended import jwt_required +from controller.security.CustomDecorators import AuthCheck, bad_token_check @bp.route("/feedback", methods=["POST"]) +@jwt_required() +@bad_token_check() +@AuthCheck() def create_new_feedback(): try: feedback_data = request.json feedback_data["lag_time"] = None - feedback_data["feedback_time"] = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') + feedback_data["feedback_time"] = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' feedback = create_feedback(request.json) diff --git a/BackEndFlask/controller/Routes/Login_route.py b/BackEndFlask/controller/Routes/Login_route.py index a8a2d3afd..fbe0c982d 100644 --- a/BackEndFlask/controller/Routes/Login_route.py +++ b/BackEndFlask/controller/Routes/Login_route.py @@ -15,7 +15,7 @@ @bp.route('/login', methods=['POST']) def login(): try: - email, password = request.args.get('email'), request.args.get('password') + email, password = request.json.get('email'), request.json.get('password') if is_any_variable_in_array_missing([email, password]): raise MissingException(["Email", "Password"]) @@ -47,7 +47,7 @@ def login(): @bp.route('/password', methods = ['PUT']) def set_new_password(): try: - email, password = request.args.get('email'), request.args.get('password') + email, password = request.json.get('email'), request.json.get('password') if is_any_variable_in_array_missing([email, password]): raise MissingException(["Email", "Password"]) diff --git a/BackEndFlask/controller/Routes/Logout_route.py b/BackEndFlask/controller/Routes/Logout_route.py index 4fd427935..8a4e415e9 100644 --- a/BackEndFlask/controller/Routes/Logout_route.py +++ b/BackEndFlask/controller/Routes/Logout_route.py @@ -2,7 +2,6 @@ from controller import bp from controller.Route_response import * from controller.security.blacklist import blacklist_token -from controller.security.CustomDecorators import AuthCheck, bad_token_check from controller.security.utility import( revoke_tokens, token_expired, @@ -13,7 +12,7 @@ @bp.route('/logout', methods=['POST']) def logout(): try: - _id, jwt, refresh = request.args.get('user_id'), request.args.get('access_token'), request.args.get('refresh_token') + _id, jwt, refresh = request.args.get('user_id'), request.json.get('access_token'), request.json.get('refresh_token') _id = to_int(_id, 'user_id') if jwt and not token_expired(jwt): diff --git a/BackEndFlask/controller/Routes/Rating_routes.py b/BackEndFlask/controller/Routes/Rating_routes.py index d0d37c629..74b1bbc26 100644 --- a/BackEndFlask/controller/Routes/Rating_routes.py +++ b/BackEndFlask/controller/Routes/Rating_routes.py @@ -5,8 +5,18 @@ from controller.Route_response import * from models.completed_assessment import * from models.queries import get_individual_ratings +from flask_jwt_extended import jwt_required + +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) @bp.route("/rating", methods=["GET"]) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() def get_student_individual_ratings(): """ Description: @@ -45,6 +55,10 @@ def get_student_individual_ratings(): @bp.route("/rating", methods=["POST"]) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() def student_view_feedback(): """ Description: @@ -61,9 +75,11 @@ def student_view_feedback(): return create_bad_response(f"Feedback already exists", "feedbacks", 409) feedback_data = request.json - feedback_data["feedback_time"] = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') - feedback = create_feedback(request.json) - + feedback_time = datetime.now() + feedback_data["feedback_time"] = feedback_time.strftime('%Y-%m-%dT%H:%M:%S') + + feedback = create_feedback(feedback_data) + return create_good_response(student_feedback_schema.dump(feedback), 200, "feedbacks") except Exception as e: diff --git a/BackEndFlask/controller/Routes/Refresh_route.py b/BackEndFlask/controller/Routes/Refresh_route.py index 697817171..ce31aad9b 100644 --- a/BackEndFlask/controller/Routes/Refresh_route.py +++ b/BackEndFlask/controller/Routes/Refresh_route.py @@ -5,7 +5,6 @@ from controller.Route_response import * from flask_jwt_extended import jwt_required, create_access_token from controller.security.CustomDecorators import AuthCheck, bad_token_check -from controller.security.CustomDecorators import AuthCheck, bad_token_check @bp.route('/refresh', methods=['POST']) @jwt_required(refresh=True) diff --git a/BackEndFlask/controller/Routes/Rubric_routes.py b/BackEndFlask/controller/Routes/Rubric_routes.py index 073d63888..49e8fc332 100644 --- a/BackEndFlask/controller/Routes/Rubric_routes.py +++ b/BackEndFlask/controller/Routes/Rubric_routes.py @@ -3,14 +3,18 @@ from models.rubric_categories import * from controller.Route_response import * from flask_jwt_extended import jwt_required -from models.rubric import get_rubric, get_rubrics, create_rubric +from models.rubric import get_rubric, get_rubrics, create_rubric, delete_rubric_by_id from models.category import get_categories_per_rubric, get_categories, get_ratings_by_category from models.suggestions import get_suggestions_per_category -from controller.security.CustomDecorators import AuthCheck, bad_token_check from models.observable_characteristics import get_observable_characteristic_per_category from models.queries import get_rubrics_and_total_categories, get_rubrics_and_total_categories_for_user_id, get_categories_for_user_id from models.user import get_user +from controller.security.CustomDecorators import( + AuthCheck, bad_token_check, + admin_check +) + @bp.route('/rubric', methods = ['GET']) @jwt_required() @bad_token_check() @@ -103,6 +107,7 @@ def get_all_rubrics(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def add_rubric(): # expects to recieve a json object with all two fields. # one named 'rubric' holds all the fields for a rubric (except rubric_id) @@ -157,6 +162,60 @@ def get_all_categories(): except Exception as e: return create_bad_response(f"An error occurred retrieving all categories: {e}", "categories", 400) +@bp.route('/rubric', methods=['PUT']) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() +def edit_rubric(): + try: + if request.args and request.args.get("rubric_id"): + rubric_id = request.args.get("rubric_id") + + data = request.json + rubric = get_rubric(rubric_id) + + rubric.rubric_name = data["rubric"].get('rubric_name', rubric.rubric_name) + rubric.rubric_description = data["rubric"].get('rubric_description', rubric.rubric_description) + + if 'categories' in data: + + delete_rubric_categories_by_rubric_id(rubric_id) + + rc = {} + rc["rubric_id"] = rubric_id + + category_ids = data.get('categories') + for category_id in category_ids: + rc["category_id"] = category_id + + create_rubric_category(rc) + + db.session.commit() + return create_good_response(rubric_schema.dump(rubric), 200, "rubrics") + + except Exception as e: + db.session.rollback() + return create_bad_response(f"An error occurred editing a rubric: {e}", "rubrics", 400) + + +@bp.route('/rubric', methods=['DELETE']) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() +def delete_rubric(): + try: + if request.args and request.args.get("rubric_id"): + rubric_id = request.args.get("rubric_id") + + delete_rubric_by_id(rubric_id) + return create_good_response([], 200, "rubric deleted successfully") + except Exception as e: + db.session.rollback() + return create_bad_response(f"An error occurred deleting a rubric: {e}", "rubrics", 400) + + class RatingsSchema(ma.Schema): class Meta: fields = ( diff --git a/BackEndFlask/controller/Routes/Signup_route.py b/BackEndFlask/controller/Routes/Signup_route.py index bd799f4aa..6f72b39be 100644 --- a/BackEndFlask/controller/Routes/Signup_route.py +++ b/BackEndFlask/controller/Routes/Signup_route.py @@ -3,7 +3,6 @@ from models.user import get_user_by_email from controller.Route_response import * from controller.Routes.User_routes import UserSchema -from controller.security.CustomDecorators import AuthCheck, bad_token_check @bp.route('/signup', methods=['POST']) def register_user(): diff --git a/BackEndFlask/controller/Routes/Team_bulk_upload_routes.py b/BackEndFlask/controller/Routes/Team_bulk_upload_routes.py index 9d763e519..47ce68eac 100644 --- a/BackEndFlask/controller/Routes/Team_bulk_upload_routes.py +++ b/BackEndFlask/controller/Routes/Team_bulk_upload_routes.py @@ -9,9 +9,18 @@ from Functions import teamBulkUpload from Functions import customExceptions from controller.Route_response import * +from flask_jwt_extended import jwt_required +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) @bp.route('/team_bulk_upload', methods=['POST']) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() def upload_team_csv(): try: file = request.files['csv_file'] diff --git a/BackEndFlask/controller/Routes/Team_routes.py b/BackEndFlask/controller/Routes/Team_routes.py index a03f45fc5..9e7de4e66 100644 --- a/BackEndFlask/controller/Routes/Team_routes.py +++ b/BackEndFlask/controller/Routes/Team_routes.py @@ -8,12 +8,21 @@ get_team_by_course_id, create_team, get_teams_by_observer_id, - replace_team + replace_team, + delete_team ) +from models.assessment_task import get_assessment_tasks_by_team_id +from models.completed_assessment import completed_assessment_team_or_user_exists from models.team_user import * -from controller.security.CustomDecorators import AuthCheck, bad_token_check + +from controller.security.CustomDecorators import( + AuthCheck, bad_token_check, + admin_check +) + from models.queries import ( - get_team_by_course_id_and_user_id + get_team_by_course_id_and_user_id, + get_all_nonfull_adhoc_teams ) @bp.route('/team', methods = ['GET']) @@ -84,11 +93,29 @@ def get_one_team(): except Exception as e: return create_bad_response(f"An error occurred fetching a team: {e}", "teams", 400) +@bp.route('/team/nonfull-adhoc', methods = ["GET"]) +@jwt_required() +@bad_token_check() +@AuthCheck() +def get_nonfull_adhoc_teams(): + # given an assessment task id, return list of team ids that have not reached the max team size + try: + if request.args and request.args.get("assessment_task_id"): + assessment_task_id = int(request.args.get("assessment_task_id")) + + valid_teams = [{"team_name": f"Team {team}", "team_id": team} for team in get_all_nonfull_adhoc_teams(assessment_task_id)] + + return create_good_response(valid_teams, 200, "teams") + + except Exception as e: + return create_bad_response(f"An error occurred getting nonfull adhoc teams {e}", "teams", 400) + @bp.route('/team', methods = ['POST']) @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def add_team(): try: new_team = create_team(request.json) @@ -103,6 +130,7 @@ def add_team(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_team(): try: team_id = request.args.get("team_id") @@ -118,6 +146,7 @@ def update_team(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_team_user_by_edit(): try: data = request.get_json() @@ -149,6 +178,34 @@ def update_team_user_by_edit(): except Exception as e: return create_bad_response(f"An error occurred updating a team: {e}", "teams", 400) +@bp.route('/team', methods = ['DELETE']) +@jwt_required() +@bad_token_check() +@AuthCheck() +def delete_selected_teams(): + try: + if request.args and request.args.get("team_id"): + team_id = int(request.args.get("team_id")) + team = get_team(team_id) + if not team: + return create_bad_response("Team does not exist", "teams", 400) + + associated_tasks = completed_assessment_team_or_user_exists(team_id, user_id=None) + if associated_tasks is None: + associated_tasks = [] + if len(associated_tasks) > 0: + refetched_tasks = completed_assessment_team_or_user_exists(team_id, user_id=None) + if not refetched_tasks: + delete_team(team_id) + return create_good_response([], 200, "teams") + else: + return create_bad_response("Cannot delete team with associated tasks", "teams", 400) + else: + delete_team(team_id) + return create_good_response([], 200, "teams") + + except Exception as e: + return create_bad_response(f"An error occurred deleting a team: {e}", "teams", 400) class TeamSchema(ma.Schema): class Meta: diff --git a/BackEndFlask/controller/Routes/Upload_csv_routes.py b/BackEndFlask/controller/Routes/Upload_csv_routes.py index 34ecab54d..120a37bad 100644 --- a/BackEndFlask/controller/Routes/Upload_csv_routes.py +++ b/BackEndFlask/controller/Routes/Upload_csv_routes.py @@ -8,12 +8,17 @@ from controller.Route_response import * from flask_jwt_extended import jwt_required import Functions.studentImport as studentImport -from controller.security.CustomDecorators import AuthCheck, bad_token_check + +from controller.security.CustomDecorators import ( + AuthCheck, bad_token_check, + admin_check +) @bp.route('/studentbulkuploadcsv', methods = ['POST']) @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def student_bulk_upload_csv(): try: file = request.files['csv_file'] diff --git a/BackEndFlask/controller/Routes/User_routes.py b/BackEndFlask/controller/Routes/User_routes.py index 1b2b1dc1c..77b475c6b 100644 --- a/BackEndFlask/controller/Routes/User_routes.py +++ b/BackEndFlask/controller/Routes/User_routes.py @@ -2,7 +2,11 @@ from controller import bp from controller.Route_response import * from flask_jwt_extended import jwt_required -from controller.security.CustomDecorators import AuthCheck, bad_token_check + +from controller.security.CustomDecorators import( + AuthCheck, bad_token_check, + admin_check +) from models.role import ( get_role @@ -36,7 +40,8 @@ get_user_password, replace_user, make_admin, - unmake_admin + unmake_admin, + delete_user_by_user_id ) from models.queries import ( @@ -45,7 +50,7 @@ get_users_by_course_id, get_users_by_course_id_and_role_id, get_students_by_team_id, - get_students_not_in_a_team, + get_active_students_not_in_a_team, add_user_to_team, remove_user_from_team ) @@ -88,7 +93,7 @@ def get_all_users(): # We are going to add students by default! # Return students that are not in the team! - all_users = get_students_not_in_a_team(course_id, team_id) + all_users = get_active_students_not_in_a_team(course_id, team_id) if request.args.get("assign") == 'true': # We are going to remove students! @@ -185,6 +190,7 @@ def get_all_team_members(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def add_user(): try: if(request.args and request.args.get("team_id")): @@ -246,6 +252,7 @@ def add_user(): @jwt_required() @bad_token_check() @AuthCheck() +@admin_check() def update_user(): try: if(request.args and request.args.get("uid") and request.args.get("course_id")): @@ -299,6 +306,24 @@ def update_user(): except Exception as e: return create_bad_response(f"An error occurred replacing a user_id: {e}", "users", 400) + +@bp.route('/user', methods = ['DELETE']) +@jwt_required() +@bad_token_check() +@AuthCheck() +@admin_check() +def delete_user(): + try: + if request.args and request.args.get("uid"): + user_id = request.args.get("uid") + + delete_user_by_user_id(user_id) + + return create_good_response([], 200, "") + + except Exception as e: + return create_bad_response(f"An error occurred replacing a user_id: {e}", "", 400) + class UserSchema(ma.Schema): @@ -315,7 +340,6 @@ class Meta: 'owner_id', 'active', 'has_set_password', - 'reset_code', 'is_admin', 'role_id' ) diff --git a/BackEndFlask/controller/security/CustomDecorators.py b/BackEndFlask/controller/security/CustomDecorators.py index 1251bc86d..6a7aee5f7 100644 --- a/BackEndFlask/controller/security/CustomDecorators.py +++ b/BackEndFlask/controller/security/CustomDecorators.py @@ -2,6 +2,8 @@ from functools import wraps from .utility import to_int from .blacklist import is_token_blacklisted +from typing import Callable +from models.queries import is_admin_by_user_id from flask_jwt_extended import decode_token from flask_jwt_extended.exceptions import ( NoAuthorizationError, @@ -53,9 +55,42 @@ def verify_token(refresh: bool): if not id: raise InvalidQueryParamError("Missing user_id") token = request.headers.get('Authorization').split()[1] try: - decoded_id = decode_token(token)['sub'] if refresh else decode_token(token)['sub'][0] + decoded_id = int(decode_token(token)['sub']) except: raise NoAuthorizationError("No Authorization") id = to_int(id, "user_id") if id == decoded_id : return - raise NoAuthorizationError("No Authorization") \ No newline at end of file + raise NoAuthorizationError("No Authorization") + +def admin_check(refresh: bool = False) -> Callable: + """ + Description: + This is a decorator that checks to make sure that the route was called by an admin permisions. + I think it is best to use the decorator as the last decorator since it hits the db. + """ + def wrapper(fn): + @wraps(fn) + def decorator(*args): + verify_admin(refresh) + return current_app.ensure_sync(fn)(*args) + return decorator + return wrapper + +def verify_admin(refresh: bool) -> None: + """ + Description: + Uses token user_id to check user permisions. + + Exceptions: + Raises NoAuthorizationError if at any instance it can not be reliably determined if + the individual that called the route has admin level permissions. + """ + try: + # Figuring out the user_id from token. + # Assumes authcheck() has already concluded token_user_id == user_id from parameters. + token = request.headers.get('Authorization').split()[1] + decoded_id = decode_token(token)['sub'] if refresh else decode_token(token)['sub'][0] + if is_admin_by_user_id(decoded_id) == False: + raise NoAuthorizationError("No Authorization") + except: + raise NoAuthorizationError("No Authorization") \ No newline at end of file diff --git a/BackEndFlask/controller/security/blacklist.py b/BackEndFlask/controller/security/blacklist.py index 3859103c7..70d68f6d7 100644 --- a/BackEndFlask/controller/security/blacklist.py +++ b/BackEndFlask/controller/security/blacklist.py @@ -1,14 +1,11 @@ import math import time -import redis import subprocess -from core import app +from core import app, red from flask_jwt_extended import decode_token from jwt.exceptions import ExpiredSignatureError import os -redis_host = os.environ.get('REDIS_HOST', 'localhost') - # Starts a Redis server as a subprocess using the subprocess.Popen function # Redirects the standard output and standard error streams to subprocess.DEVNULL to get rid of them def start_redis() -> None: @@ -21,9 +18,7 @@ def start_redis() -> None: # Checks if a given token exists in a Redis database and returns True if it is blacklisted def is_token_blacklisted(token: str) -> bool: try: - r = redis.Redis(host=redis_host, port=6379, db=0, decode_responses=True) - found = r.get(token) - r.close() + found = red.get(token) return True if found else False except ConnectionError: print('connection error') @@ -33,10 +28,8 @@ def is_token_blacklisted(token: str) -> bool: def blacklist_token(token: str) -> None: with app.app_context(): try: - r = redis.Redis(host=redis_host, port=6379, db=0, decode_responses=True) expiration = math.ceil(decode_token(token)['exp'] - time.time()) - r.set(token, r.dbsize()+1, ex=expiration) - r.close() + red.set(token, red.dbsize()+1, ex=expiration) except ExpiredSignatureError: return diff --git a/BackEndFlask/controller/security/utility.py b/BackEndFlask/controller/security/utility.py index 40dbb8692..5825203e9 100644 --- a/BackEndFlask/controller/security/utility.py +++ b/BackEndFlask/controller/security/utility.py @@ -1,3 +1,5 @@ +import traceback +import datetime from flask import request from core import app from jwt import ExpiredSignatureError @@ -18,10 +20,10 @@ # jwt expires in 15mins; refresh token expires in 30days def create_tokens(user_i_d: any) -> 'tuple[str, str]': with app.app_context(): - jwt = create_access_token([user_i_d]) + jwt = create_access_token(str(user_i_d), fresh=datetime.timedelta(minutes=60)) refresh = request.args.get('refresh_token') if not refresh: - refresh = create_refresh_token(user_i_d) + refresh = create_refresh_token(str(user_i_d)) return jwt, refresh # Takes away jwt and refresh tokens from response @@ -52,8 +54,7 @@ def token_expired(thing: str) -> bool: # Function returns the user_id from the sub of the jwt def token_user_id(thing: str, refresh: bool = False) -> int: with app.app_context(): - if refresh: return decode_token(thing)['sub'] - return decode_token(thing)['sub'][0] + return int(decode_token(thing)['sub']) # Handles conversion issues and warns front end of problems def to_int(thing: str , subject: str) -> int: diff --git a/BackEndFlask/core/__init__.py b/BackEndFlask/core/__init__.py index c4b7d3321..d70f6939a 100644 --- a/BackEndFlask/core/__init__.py +++ b/BackEndFlask/core/__init__.py @@ -10,33 +10,51 @@ import sys import os import re +import redis def setup_cron_jobs(): - # Set up cron jobs - pull_cron_jobs = subprocess.run( - ["crontab", "-l"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - - # Check if crontab exists - if pull_cron_jobs.returncode != 0 and "no crontab for" in pull_cron_jobs.stderr: - current_cron = "" - else: - current_cron = pull_cron_jobs.stdout - - find_job = re.search( - r".*rm -f.*tempCsv/\*.*", - current_cron - ) - - if not find_job: - cron_path = os.path.abspath(".") + "/cronJobs" - with open(cron_path, "w") as f: - f.write(current_cron) - f.write("0 3 * * * rm -f " + os.path.abspath(".") + "/tempCsv/*\n") - - subprocess.run(["crontab", cron_path]) - os.remove(cron_path) + # Check if we've already set up cron + flag_file = os.path.join(os.path.dirname(__file__), '.cron_setup_complete') + + # If we've already set up cron, skip + if os.path.exists(flag_file): + return + + try: + # Set up cron jobs + pull_cron_jobs = subprocess.run( + ["crontab", "-l"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + # Check if crontab exists + if pull_cron_jobs.returncode != 0 and "no crontab for" in pull_cron_jobs.stderr: + current_cron = "" + else: + current_cron = pull_cron_jobs.stdout + + find_job = re.search( + r".*rm -f.*tempCsv/\*.*", + current_cron + ) + + if not find_job: + cron_path = os.path.abspath(".") + "/cronJobs" + with open(cron_path, "w") as f: + f.write(current_cron) + # Fixed the crontab syntax to include all 5 time fields + f.write("0 3 * * * rm -f " + os.path.abspath(".") + "/tempCsv/*\n") + + subprocess.run(["crontab", cron_path]) + os.remove(cron_path) + + # Create flag file after successful setup + with open(flag_file, 'w') as f: + f.write(f'Cron setup completed at: {subprocess.check_output(["date"]).decode().strip()}\n') + + except Exception as e: + # Log any errors but don't prevent app from starting + print(f"Warning: Cron setup failed: {str(e)}") # Check if we should skip crontab setup SKIP_CRONTAB_SETUP = os.getenv('SKIP_CRONTAB_SETUP', 'false').lower() == 'true' @@ -79,6 +97,10 @@ def setup_cron_jobs(): db = SQLAlchemy(app) ma = Marshmallow(app) +redis_host = os.environ.get('REDIS_HOST', 'localhost') + +red = redis.Redis(host=redis_host, port=6379, db=0, decode_responses=True) + # Register blueprints from controller import bp -app.register_blueprint(bp, url_prefix='/api') \ No newline at end of file +app.register_blueprint(bp, url_prefix='/api') diff --git a/BackEndFlask/models/assessment_task.py b/BackEndFlask/models/assessment_task.py index d464216e6..243e201df 100644 --- a/BackEndFlask/models/assessment_task.py +++ b/BackEndFlask/models/assessment_task.py @@ -2,6 +2,7 @@ from models.schemas import AssessmentTask, Team from datetime import datetime from models.utility import error_log +from models.checkin import delete_checkins_over_team_count, delete_latest_checkins_over_team_size """ Something to consider may be the due_date as the default @@ -22,6 +23,13 @@ def __init__(self): def __str__(self): return self.message + +class InvalidMaxTeamSize(Exception): + def __init__(self): + self.message = "Number of people on a team must be greater than 0." + + def __str__(self): + return self.message def validate_number_of_teams(number_of_teams): if number_of_teams is not None: @@ -31,6 +39,15 @@ def validate_number_of_teams(number_of_teams): raise InvalidNumberOfTeams() except ValueError: raise InvalidNumberOfTeams() + +def validate_max_team_size(max_team_size): + if max_team_size is not None: + try: + number = int(max_team_size) + if number <= 0: + raise InvalidMaxTeamSize() + except ValueError: + raise InvalidMaxTeamSize() @error_log def get_assessment_tasks(): @@ -38,7 +55,7 @@ def get_assessment_tasks(): @error_log def get_assessment_tasks_by_course_id(course_id): - return AssessmentTask.query.filter_by(course_id=course_id).all() + return AssessmentTask.query.filter_by(course_id=course_id).all() # query completed assesment tasks @error_log def get_assessment_tasks_by_role_id(role_id): @@ -46,7 +63,7 @@ def get_assessment_tasks_by_role_id(role_id): @error_log def get_assessment_tasks_by_team_id(team_id): - return db.session.query(AssessmentTask).join(Team, AssessmentTask.course_id == Team.course_id).filter( + db.session.query(AssessmentTask).join(Team, AssessmentTask.course_id == Team.course_id).filter( Team.team_id == team_id and ( @@ -55,7 +72,6 @@ def get_assessment_tasks_by_team_id(team_id): (AssessmentTask.due_date >= Team.date_created and AssessmentTask.due_date <= Team.active_until) ) ).all() - @error_log def get_assessment_task(assessment_task_id): one_assessment_task = AssessmentTask.query.filter_by(assessment_task_id=assessment_task_id).first() @@ -74,6 +90,7 @@ def create_assessment_task(assessment_task): assessment_task["due_date"] = assessment_task["due_date"] + "Z" validate_number_of_teams(assessment_task["number_of_teams"]) + validate_max_team_size(assessment_task["max_team_size"]) new_assessment_task = AssessmentTask( assessment_task_name=assessment_task["assessment_task_name"], @@ -88,6 +105,7 @@ def create_assessment_task(assessment_task): create_team_password=assessment_task["create_team_password"], comment=assessment_task["comment"], number_of_teams=assessment_task["number_of_teams"], + max_team_size=assessment_task["max_team_size"], notification_sent=None ) @@ -104,6 +122,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_cta", "due_date": "2023-04-24T08:30:00", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 1, "show_ratings": True, @@ -117,6 +136,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_fca", "due_date": "2023-03-03T13:00:00", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 2, "show_ratings": True, @@ -130,6 +150,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_ipa", "due_date": "2023-02-14T08:00:00", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 3, "show_ratings": False, @@ -143,6 +164,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_ic", "due_date": "2023-03-05T09:30:00", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 4, "show_ratings": False, @@ -156,6 +178,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_ma", "due_date": "2023-05-29T13:20:00", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 5, "show_ratings": True, @@ -169,6 +192,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_psa", "due_date": "2023-02-13T10:00:00", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 6, "show_ratings": False, @@ -182,6 +206,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "at_ta", "due_date": "2023-01-09T09:30:00", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 1, "show_ratings": False, @@ -195,6 +220,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-01-30T21:00:24", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 1, "show_ratings": True, @@ -208,6 +234,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-01-28T21:25:20.216000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 2, "show_ratings": True, @@ -221,6 +248,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-01-30T15:10:18.708000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 2, "show_ratings": True, @@ -234,6 +262,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-01-30T15:12:16.247000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 7, "show_ratings": True, @@ -247,6 +276,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:01:10.164000", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 6, "show_ratings": True, @@ -260,6 +290,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:06:49.746000", "number_of_teams": None, + "max_team_size": None, "role_id": 5, "rubric_id": 6, "show_ratings": True, @@ -273,6 +304,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:09:44.900000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 3, "show_ratings": True, @@ -286,6 +318,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "asdf", "due_date": "2024-02-05T17:10:06.960000", "number_of_teams": 7, + "max_team_size": 4, "role_id": 4, "rubric_id": 4, "show_ratings": True, @@ -299,6 +332,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:10:48.660000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 1, "show_ratings": True, @@ -312,6 +346,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:11:05.896000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 2, "show_ratings": True, @@ -325,6 +360,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:11:26.842000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 5, "show_ratings": True, @@ -338,6 +374,7 @@ def load_demo_admin_assessment_task(): "create_team_password": "", "due_date": "2024-02-05T17:11:44.486000", "number_of_teams": None, + "max_team_size": None, "role_id": 4, "rubric_id": 3, "show_ratings": True, @@ -360,7 +397,8 @@ def load_demo_admin_assessment_task(): "unit_of_assessment": assessment["unit_of_assessment"], "create_team_password": assessment["create_team_password"], "comment": assessment["comment"], - "number_of_teams": assessment["number_of_teams"] + "number_of_teams": assessment["number_of_teams"], + "max_team_size": assessment["max_team_size"] }) @error_log @@ -372,12 +410,21 @@ def replace_assessment_task(assessment_task, assessment_task_id): assessment_task["due_date"] = assessment_task["due_date"] + "Z" validate_number_of_teams(assessment_task["number_of_teams"]) + validate_max_team_size(assessment_task["max_team_size"]) one_assessment_task = AssessmentTask.query.filter_by(assessment_task_id=assessment_task_id).first() if one_assessment_task is None: raise InvalidAssessmentTaskID(assessment_task_id) + # Kick all members from ad hoc teams beyond new team count if there are now fewer teams + if assessment_task["number_of_teams"] is not None and one_assessment_task.number_of_teams > int(assessment_task["number_of_teams"]): + delete_checkins_over_team_count(assessment_task_id, int(assessment_task["number_of_teams"])) + + # Kick all members from ad hoc teams with too many members if there is now a smaller capacity + if assessment_task["max_team_size"] is not None and one_assessment_task.max_team_size > int(assessment_task["max_team_size"]): + delete_latest_checkins_over_team_size(assessment_task_id, int(assessment_task["max_team_size"])) + one_assessment_task.assessment_task_name = assessment_task["assessment_task_name"] one_assessment_task.course_id = assessment_task["course_id"] one_assessment_task.due_date=datetime.strptime(assessment_task["due_date"], '%Y-%m-%dT%H:%M:%S.%fZ') @@ -389,7 +436,9 @@ def replace_assessment_task(assessment_task, assessment_task_id): one_assessment_task.unit_of_assessment = assessment_task["unit_of_assessment"] one_assessment_task.create_team_password = assessment_task["create_team_password"] one_assessment_task.comment = assessment_task["comment"] - + one_assessment_task.number_of_teams = assessment_task["number_of_teams"] + one_assessment_task.max_team_size = assessment_task["max_team_size"] + db.session.commit() return one_assessment_task diff --git a/BackEndFlask/models/checkin.py b/BackEndFlask/models/checkin.py index 748b11855..434bc5e4b 100644 --- a/BackEndFlask/models/checkin.py +++ b/BackEndFlask/models/checkin.py @@ -33,4 +33,44 @@ def update_checkin(new_checkin): def get_checkins_by_assessment(assessment_task_id): checkins = Checkin.query.filter_by(assessment_task_id=assessment_task_id).all() - return checkins \ No newline at end of file + return checkins + +# Generated with ChatGPT +@error_log +def delete_checkins_over_team_count(assessment_task_id, number_of_teams): + Checkin.query.filter( + Checkin.assessment_task_id == assessment_task_id, + Checkin.team_number > number_of_teams + ).delete() + + db.session.commit() + +# Generated with ChatGPT +@error_log +def delete_latest_checkins_over_team_size(assessment_task_id, max_team_size): + # Get all checkins for the assessment task + checkins = Checkin.query.filter_by(assessment_task_id=assessment_task_id).all() + + # Create a dictionary to count checkins per team member + team_checkin_count = {} + + # Count the number of checkins for each team + for checkin in checkins: + if checkin.team_number in team_checkin_count: + team_checkin_count[checkin.team_number] += 1 + else: + team_checkin_count[checkin.team_number] = 1 + + # Loop through each team and remove checkins if they exceed max_team_size + for team_number, count in team_checkin_count.items(): + while count > max_team_size: + # Find the latest checkin for this team + latest_checkin = Checkin.query.filter_by(assessment_task_id=assessment_task_id, team_number=team_number).order_by(Checkin.time.desc()).first() + + if latest_checkin: + db.session.delete(latest_checkin) + count -= 1 + else: + break + + db.session.commit() \ No newline at end of file diff --git a/BackEndFlask/models/completed_assessment.py b/BackEndFlask/models/completed_assessment.py index 7052fcc07..bcd4fef93 100644 --- a/BackEndFlask/models/completed_assessment.py +++ b/BackEndFlask/models/completed_assessment.py @@ -1,10 +1,13 @@ from core import db -from sqlalchemy import and_ +from sqlalchemy import and_, func from sqlalchemy.exc import SQLAlchemyError from models.schemas import CompletedAssessment, AssessmentTask, User, Feedback from datetime import datetime from models.utility import error_log +# new function to read number of records for a particular assessment task id, if 0 disable the export button, ask how many get_completed_assessment_by_course +# get total assessments by course id and return it to assessment taks cancatenate that to the + class InvalidCRID(Exception): def __init__(self, id): self.message = f"Invalid completed_assessment_id: {id}." @@ -39,6 +42,9 @@ def get_completed_assessment_by_course_id(course_id): AssessmentTask.course_id == course_id ).all() +@error_log +def get_completed_assessment_count(assessment_task_id): + return db.session.query(func.count(CompletedAssessment.completed_assessment_id)).filter_by(assessment_task_id=assessment_task_id).scalar() @error_log def completed_assessment_exists(team_id, assessment_task_id, user_id): @@ -47,6 +53,14 @@ def completed_assessment_exists(team_id, assessment_task_id, user_id): else: return CompletedAssessment.query.filter_by(user_id=user_id, assessment_task_id=assessment_task_id).first() +@error_log +def completed_assessment_team_or_user_exists(team_id, user_id): + if team_id is not None: + return CompletedAssessment.query.filter_by(team_id=team_id).all() + elif user_id is not None: + return CompletedAssessment.query.filter_by(user_id=user_id).all() + else: + return [] @error_log def create_completed_assessment(completed_assessment_data): diff --git a/BackEndFlask/models/loadExistingRubrics.py b/BackEndFlask/models/loadExistingRubrics.py index bd3015837..686afc0fd 100644 --- a/BackEndFlask/models/loadExistingRubrics.py +++ b/BackEndFlask/models/loadExistingRubrics.py @@ -175,7 +175,7 @@ def load_existing_observable_characteristics(): [19, "Patiently listened without interrupting the speaker"], [19, "Referenced others' ideas to indicate listening and understanding"], [19, "Presented nonverbal cues to indicate attentiveness"], - [19, "Avoided engagine in activities that diverted attention"], + [19, "Avoided engaging in activities that diverted attention"], # Responding Observable Characteristics 1-4 [20, "Acknowledged other members for their ideas or contributions"], [20, "Rephrased or referred to what other group members have said"], diff --git a/BackEndFlask/models/logger.py b/BackEndFlask/models/logger.py index 61d4bafc3..e50494eb8 100644 --- a/BackEndFlask/models/logger.py +++ b/BackEndFlask/models/logger.py @@ -137,4 +137,12 @@ def critical(self, msg: str) -> None: self.__try_clear() self.logger.critical(msg) -logger = Logger("rubricapp_logger") \ No newline at end of file + def password_reset(self, user_id:str, lms_id:str, first_name:str, last_name:str, email:str): + self.__try_clear() + log_msg = (f"Password Reset Request - User: {user_id}, " + f"LMS: {lms_id}, " + f"Name: {first_name} {last_name}, " + f"Email: {email},") + self.logger.info(log_msg) + +logger = Logger("rubricapp_logger") diff --git a/BackEndFlask/models/queries.py b/BackEndFlask/models/queries.py index 83a0e9633..0c245848a 100644 --- a/BackEndFlask/models/queries.py +++ b/BackEndFlask/models/queries.py @@ -1,6 +1,8 @@ from core import db from models.utility import error_log from models.schemas import * +from sqlalchemy.sql import text +from sqlalchemy import func from models.team_user import ( create_team_user, @@ -30,13 +32,15 @@ from sqlalchemy import ( and_, or_, - union + union, + select, + case, + literal_column ) import sqlalchemy - @error_log def get_courses_by_user_courses_by_user_id(user_id): """ @@ -274,7 +278,7 @@ def get_students_by_team_id(course_id: int, team_id: int): @error_log -def get_students_not_in_a_team(course_id: int, team_id: int): +def get_active_students_not_in_a_team(course_id: int, team_id: int): """ Description: Gets all of the students not assigned to a team. @@ -296,6 +300,7 @@ def get_students_not_in_a_team(course_id: int, team_id: int): and_( UserCourse.course_id == course_id, UserCourse.role_id == 5, + UserCourse.active == True, UserCourse.user_id.notin_( db.session.query( TeamUser.user_id @@ -717,6 +722,50 @@ def get_all_checkins_for_assessment(assessment_task_id): return checkins +# This query was written by ChatGPT +@error_log +def get_all_nonfull_adhoc_teams(assessment_task_id): + """ + Description: + Gets all team numbers where the number of users checked into a team + does not exceed the max_team_size for the given assessment task + and returns only the team numbers up to number_of_teams. + + Parameters: + assessment_task_id: int (The id of an assessment task) + """ + # Get the max_team_size and number_of_teams for the given assessment_task_id + assessment_task = db.session.query( + AssessmentTask + ).filter( + AssessmentTask.assessment_task_id == assessment_task_id + ).first() + + if not assessment_task: + return [] # No assessment task found + + max_team_size = assessment_task.max_team_size + number_of_teams = assessment_task.number_of_teams + + # Query to get all team_numbers where the team size is less than max_team_size + teams_above_max_size = db.session.query( + Checkin.team_number + ).filter( + Checkin.assessment_task_id == assessment_task_id + ).group_by( + Checkin.team_number + ).having( + db.func.count(Checkin.user_id) >= max_team_size + ).all() + + # Extracting team numbers from the result tuples + invalid_team_numbers = {team[0] for team in teams_above_max_size} + + # Generate a list of all team numbers up to number_of_teams + all_team_numbers = set(range(1, number_of_teams + 1)) + + # Return only those team numbers that are not invalid + return list(all_team_numbers - invalid_team_numbers) @error_log def get_completed_assessment_with_team_name(assessment_task_id): @@ -906,21 +955,7 @@ def get_csv_data_by_at_id(at_id: int) -> list[dict[str]]: at_id: int (The id of an assessment task) Return: - list[dict][str] - """ - - """ - Note that the current plan sqlite3 seems to execute is: - QUERY PLAN - |--SCAN CompletedAssessment - |--SEARCH AssessmentTask USING INTEGER PRIMARY KEY (rowid=?) - |--SEARCH Role USING INTEGER PRIMARY KEY (rowid=?) - |--SEARCH Team USING INTEGER PRIMARY KEY (rowid=?) - `--SEARCH User USING INTEGER PRIMARY KEY (rowid=?) - Untested but assume other tables are also runing a search instead of a scan - everywhere where there is no index to scan by. - The problem lies in the search the others are doing. Future speed optimications - can be reached by implementing composite indices. + list[dict][str]: (List of dicts: Each list is another individual in the AT and the dict is there related data.) """ pertinent_assessments = db.session.query( AssessmentTask.assessment_task_name, @@ -928,13 +963,15 @@ def get_csv_data_by_at_id(at_id: int) -> list[dict[str]]: AssessmentTask.rubric_id, Rubric.rubric_name, Role.role_name, + Team.team_id, Team.team_name, + CompletedAssessment.user_id, User.first_name, User.last_name, CompletedAssessment.last_update, Feedback.feedback_time, AssessmentTask.notification_sent, - CompletedAssessment.rating_observable_characteristics_suggestions_data + CompletedAssessment.rating_observable_characteristics_suggestions_data, ).join( Role, AssessmentTask.role_id == Role.role_id, @@ -944,7 +981,7 @@ def get_csv_data_by_at_id(at_id: int) -> list[dict[str]]: ).outerjoin( Team, CompletedAssessment.team_id == Team.team_id - ).join( + ).outerjoin( User, CompletedAssessment.user_id == User.user_id ).join( @@ -958,18 +995,23 @@ def get_csv_data_by_at_id(at_id: int) -> list[dict[str]]: ) ).filter( AssessmentTask.assessment_task_id == at_id + ).order_by( + User.user_id, ).all() return pertinent_assessments - -def get_csv_categories(rubric_id: int) -> tuple[dict[str],dict[str]]: +def get_csv_categories(rubric_id: int, user_id: int, team_id: int, at_id: int, category_name: str) -> tuple[dict[str],dict[str]]: """ Description: Returns the sfi and the oc data to fill out the csv file. Parameters: rubric_id : int (The id of a rubric) + user_id : int (The id of the current logged student user) + team_id: int (The id of a team) + at_id: int (The id of an assessment task) + category_name : str (The category that the ocs and sfis must relate to.) Return: tuple two Dict [Dict] [str] (All of the sfi and oc data) """ @@ -979,34 +1021,109 @@ def get_csv_categories(rubric_id: int) -> tuple[dict[str],dict[str]]: for performance reasons later down the road. The decision depends on how the database evolves from now. """ - sfi_data = db.session.query( - RubricCategory.rubric_id, - SuggestionsForImprovement.suggestion_text + + ocs_sfis_query = [None, None] + + for i in range(0, 2): + ocs_sfis_query[i] = db.session.query( + ObservableCharacteristic.observable_characteristic_text if i == 0 else SuggestionsForImprovement.suggestion_text + ).join( + Category, + (ObservableCharacteristic.category_id if i == 0 else SuggestionsForImprovement.category_id) == Category.category_id + ).join( + RubricCategory, + RubricCategory.category_id == Category.category_id + ).join( + AssessmentTask, + AssessmentTask.rubric_id == RubricCategory.rubric_id + ).join( + CompletedAssessment, + CompletedAssessment.assessment_task_id == AssessmentTask.assessment_task_id + ).filter( + Category.category_name == category_name, + CompletedAssessment.user_id == user_id, + AssessmentTask.assessment_task_id == at_id, + RubricCategory.rubric_id == rubric_id, + ).order_by( + ObservableCharacteristic.observable_characteristics_id if i == 0 else SuggestionsForImprovement.suggestion_id + ) + + if team_id is not None : ocs_sfis_query[i].filter(CompletedAssessment.team_id == team_id) + + # Executing the query + ocs = ocs_sfis_query[0].all() + sfis = ocs_sfis_query[1].all() + + return ocs,sfis + +def get_course_name_by_at_id(at_id:int) -> str : + """ + Description: + Returns a string of the course name associated to the assessment_task_id. + + Parameters: + at_id: int (The assessment_task_id that you want the course name of.) + + Returns: + Course name as a string. + + Exceptions: + None except the ones sqlalchemy + flask may raise. + """ + + course_name = db.session.query( + Course.course_name ).join( - Category, - Category.category_id == RubricCategory.rubric_category_id - ).outerjoin( - SuggestionsForImprovement, - Category.category_id == SuggestionsForImprovement.category_id + AssessmentTask, + AssessmentTask.course_id == Course.course_id ).filter( - RubricCategory.rubric_id == rubric_id - ).order_by( - RubricCategory.rubric_id + AssessmentTask.assessment_task_id == at_id ).all() - oc_data = db.session.query( - RubricCategory.rubric_id, - ObservableCharacteristic.observable_characteristic_text - ).join( - Category, - Category.category_id == RubricCategory.rubric_category_id - ).outerjoin( - ObservableCharacteristic, - Category.category_id == ObservableCharacteristic.category_id + return course_name[0][0] + + + + +def get_completed_assessment_ratio(course_id: int, assessment_task_id: int) -> int: + """ + Description: + Returns the ratio of users who have completed an assessment task + + Parameters: + course_id : int (The id of a course) + assessment_task_id : int (The id of an assessment task) + + Return: int (Ratio of users who have completed an assessment task rounded to the nearest whole number) + """ + all_usernames_for_completed_task = get_completed_assessment_with_user_name(assessment_task_id) + all_students_in_course = get_users_by_course_id_and_role_id(course_id, 5) + ratio = (len(all_usernames_for_completed_task) / len(all_students_in_course)) * 100 + + ratio_rounded = round(ratio) + + return ratio_rounded + +def is_admin_by_user_id(user_id: int) -> bool: + """ + Description: + Returns whether a certain user_id is a admin. + + Parameters: + user_id: int (User id) + + Returns: + (if the user_id is an admin) + + Exceptions: None other than what the db may raise. + """ + + is_admin = db.session.query( + User.is_admin ).filter( - RubricCategory.rubric_id == rubric_id - ).order_by( - RubricCategory.rubric_id + User.user_id == user_id ).all() - return sfi_data,oc_data \ No newline at end of file + if is_admin[0][0]: + return True + return False \ No newline at end of file diff --git a/BackEndFlask/models/rubric.py b/BackEndFlask/models/rubric.py index ae76c7b60..ee514f5b8 100644 --- a/BackEndFlask/models/rubric.py +++ b/BackEndFlask/models/rubric.py @@ -1,6 +1,6 @@ from core import db from sqlalchemy import or_ -from models.schemas import Rubric +from models.schemas import Rubric, AssessmentTask from models.utility import error_log class InvalidRubricID(Exception): @@ -58,4 +58,25 @@ def replace_rubric(rubric, rubric_id): db.session.commit() - return one_rubric \ No newline at end of file + return one_rubric + +# Generated with ChatGPT +@error_log +def delete_rubric_by_id(rubric_id): + # Find the rubric by ID + one_rubric = Rubric.query.filter_by(rubric_id=rubric_id).first() + + # Raise an error if the rubric does not exist + if one_rubric is None: + raise InvalidRubricID(rubric_id) + + # Check if the rubric is used in any assessment tasks + is_used_in_assessment = AssessmentTask.query.filter_by(rubric_id=rubric_id).count() > 0 + if is_used_in_assessment: + raise ValueError(f"Cannot delete rubric {rubric_id} as it is used in one or more assessment tasks.") + + # Proceed to delete if the rubric is unused + db.session.delete(one_rubric) + db.session.commit() + + return one_rubric diff --git a/BackEndFlask/models/rubric_categories.py b/BackEndFlask/models/rubric_categories.py index 0379d43e4..99a2d8327 100644 --- a/BackEndFlask/models/rubric_categories.py +++ b/BackEndFlask/models/rubric_categories.py @@ -12,4 +12,13 @@ def create_rubric_category(rubric_category): db.session.add(new_category) db.session.commit() - return new_category \ No newline at end of file + return new_category + +@error_log +def delete_rubric_categories_by_rubric_id(rubric_id): + categories_to_delete = RubricCategory.query.filter_by(rubric_id=rubric_id).all() + + for category in categories_to_delete: + db.session.delete(category) + + db.session.commit() \ No newline at end of file diff --git a/BackEndFlask/models/schemas.py b/BackEndFlask/models/schemas.py index af78950ec..433602e6d 100644 --- a/BackEndFlask/models/schemas.py +++ b/BackEndFlask/models/schemas.py @@ -86,7 +86,7 @@ class Course(db.Model): year = db.Column(db.Integer, nullable=False) term = db.Column(db.Text, nullable=False) active = db.Column(db.Boolean, nullable=False) - admin_id = db.Column(db.Integer, ForeignKey(User.user_id), nullable=False) + admin_id = db.Column(db.Integer, ForeignKey(User.user_id, ondelete='RESTRICT'), nullable=False) use_tas = db.Column(db.Boolean, nullable=False) use_fixed_teams = db.Column(db.Boolean, nullable=False) @@ -104,7 +104,7 @@ class Team(db.Model): # keeps track of default teams for a fixed team scenario team_id = db.Column(db.Integer, primary_key=True, autoincrement=True) team_name = db.Column(db.Text, nullable=False) course_id = db.Column(db.Integer, ForeignKey(Course.course_id), nullable=False) - observer_id = db.Column(db.Integer, ForeignKey(User.user_id), nullable=False) + observer_id = db.Column(db.Integer, ForeignKey(User.user_id, ondelete='RESTRICT'), nullable=False) date_created = db.Column(db.Date, nullable=False) active_until = db.Column(db.Date, nullable=True) @@ -129,6 +129,7 @@ class AssessmentTask(db.Model): comment = db.Column(db.Text, nullable=True) create_team_password = db.Column(db.Text, nullable=True) number_of_teams = db.Column(db.Integer, nullable=True) + max_team_size = db.Column(db.Integer, nullable=True) notification_sent = db.Column(DateTime(timezone=True), nullable=True) class Checkin(db.Model): # keeps students checking to take a specific AT diff --git a/BackEndFlask/models/user.py b/BackEndFlask/models/user.py index f4970b178..f3011ec6a 100644 --- a/BackEndFlask/models/user.py +++ b/BackEndFlask/models/user.py @@ -1,15 +1,10 @@ from core import db - from werkzeug.security import generate_password_hash, check_password_hash - from models.schemas import User, UserCourse - from sqlalchemy import ( and_ ) - from models.utility import generate_random_password, send_new_user_email - from dotenv import load_dotenv load_dotenv() @@ -186,7 +181,7 @@ def create_user(user_data): user_data = User( first_name=user_data["first_name"], last_name=user_data["last_name"], - email=user_data["email"], + email=user_data["email"].lower().strip(), password=password_hash, lms_id=user_data["lms_id"], consent=user_data["consent"], @@ -382,3 +377,13 @@ def delete_user(user_id): db.session.commit() return True + + +@error_log +def delete_user_by_user_id(user_id: int) -> bool: + user = User.query.filter_by(user_id=user_id).first() + + db.session.delete(user) + db.session.commit() + + return True \ No newline at end of file diff --git a/FrontEndReact/.gitignore b/FrontEndReact/.gitignore index f0fdfd6b3..035c76a54 100644 --- a/FrontEndReact/.gitignore +++ b/FrontEndReact/.gitignore @@ -18,7 +18,8 @@ .env.development.local .env.test.local .env.production.local +.env.production npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* diff --git a/FrontEndReact/package.json b/FrontEndReact/package.json index ef05a60c2..0861772c4 100644 --- a/FrontEndReact/package.json +++ b/FrontEndReact/package.json @@ -24,6 +24,7 @@ "dayjs": "^1.11.10", "dotenv": "^16.3.1", "dotenv-expand": "^10.0.0", + "eventsource-client": "^1.1.3", "jest-dom": "^4.0.0", "mui-datatables": "^4.3.0", "mui-one-time-password-input": "^2.0.1", @@ -70,6 +71,7 @@ "@babel/preset-typescript": "^7.23.3", "@testing-library/react": "^14.1.2", "jest": "^29.7.0", - "react-test-renderer": "^18.2.0" + "react-test-renderer": "^18.2.0", + "resize-observer-polyfill": "1.5.1" } } diff --git a/FrontEndReact/src/View/Admin/Add/AddCourse/AdminAddCourse.js b/FrontEndReact/src/View/Admin/Add/AddCourse/AdminAddCourse.js index 32c9a7bb3..0f4ddb821 100644 --- a/FrontEndReact/src/View/Admin/Add/AddCourse/AdminAddCourse.js +++ b/FrontEndReact/src/View/Admin/Add/AddCourse/AdminAddCourse.js @@ -168,14 +168,20 @@ class AdminAddCourse extends Component { "use_fixed_teams": useFixedTeams }) + let promise; + if (navbar.state.addCourse) { - genericResourcePOST("/course", this, body); + promise = genericResourcePOST("/course", this, body); } else { - genericResourcePUT(`/course?course_id=${navbar.state.course["course_id"]}`, this, body); + promise = genericResourcePUT(`/course?course_id=${navbar.state.course["course_id"]}`, this, body); } - confirmCreateResource("Course"); + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + confirmCreateResource("Course"); + } + }); }; hasErrors = () => { diff --git a/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AddCustomRubric.js b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AddCustomRubric.js index c9626f360..a7fa87f9b 100644 --- a/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AddCustomRubric.js +++ b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AddCustomRubric.js @@ -4,11 +4,12 @@ import { Grid, IconButton, TextField, Tooltip, FormControl } from "@mui/material import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import CustomButton from "./Components/CustomButton.js"; import ErrorMessage from "../../../Error/ErrorMessage.js"; -import { genericResourcePOST } from "../../../../utility"; +import { genericResourcePOST, genericResourcePUT, genericResourceGET, genericResourceDELETE } from "../../../../utility.js"; import CustomDataTable from "../../../Components/CustomDataTable.js"; import CollapsableRubricCategoryTable from "./CollapsableRubricCategoryTable.js"; import ImageModal from "./CustomRubricModal.js"; import RubricDescriptionsImage from "../../../../../src/RubricDetailedOverview.png"; +import Loading from '../../../Loading/Loading.js'; import FormHelperText from '@mui/material/FormHelperText'; class AddCustomRubric extends React.Component { @@ -16,10 +17,14 @@ class AddCustomRubric extends React.Component { super(props); this.state = { - selectedCategories: {}, + categories: [], errorMessage: null, isLoaded: null, isHelpOpen: false, + addCustomRubric: true, + defaultRubrics: this.props.rubrics, + allCategories: this.props.categories, + rubrics: null, errors: { rubricName: '', @@ -35,38 +40,33 @@ class AddCustomRubric extends React.Component { }; this.handleCreateRubric = (pickedCategories) => { + var navbar = this.props.navbar; + var rubricId = navbar.rubricId; var categoryIds = []; + var rubricName = document.getElementById("rubricNameInput").value + var rubricDescription = document.getElementById("rubricDescriptionInput").value - for ( - var categoryIndex = 0; - categoryIndex < pickedCategories.length; - categoryIndex++ - ) { - categoryIds = [ - ...categoryIds, - pickedCategories[categoryIndex]["category_id"], - ]; + for (var categoryIndex = 0; categoryIndex < pickedCategories.length; categoryIndex++) { + categoryIds.push(pickedCategories[categoryIndex]["category_id"]); } - if (document.getElementById("rubricNameInput").value === "") { + if (rubricName === "") { this.setState({ errors: { rubricName: "Missing New Rubric Name." } }); - return; - } + } - if (document.getElementById("rubricDescriptionInput").value === "") { + if (rubricDescription === "") { this.setState({ errors: { rubricDescription: "Missing New Rubric Description." } }); - return; - } + } if (categoryIds.length === 0) { this.setState({ @@ -75,50 +75,87 @@ class AddCustomRubric extends React.Component { rubricCategories: "Missing categories, at least one category must be selected.", } }); - return; - } - + } var cookies = new Cookies(); + let promise; + if (this.state.addCustomRubric === false) { + promise = genericResourcePUT( + `/rubric?rubric_id=${rubricId}`, + this, + JSON.stringify({ + rubric: { + rubric_name: rubricName, + rubric_description: rubricDescription, + owner: cookies.get("user")["user_id"], + }, + categories: categoryIds, + }), + ); + } else { + promise = genericResourcePOST( + `/rubric`, + this, + JSON.stringify({ + rubric: { + rubric_name: rubricName, + rubric_description: rubricDescription, + owner: cookies.get("user")["user_id"], + }, + categories: categoryIds, + }), + ); + } + + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + this.props.navbar.confirmCreateResource("MyCustomRubrics"); + } + }); + }; + + this.handleDeleteRubric = (rubricId) => { + var navbar = this.props.navbar; - genericResourcePOST( - `/rubric`, - this, - JSON.stringify({ - rubric: { - rubric_name: document.getElementById("rubricNameInput").value, - rubric_description: document.getElementById( - "rubricDescriptionInput", - ).value, - owner: cookies.get("user")["user_id"], - }, - - categories: categoryIds, - }), - ); + genericResourceDELETE(`/rubric?rubric_id=${rubricId}`, this); - this.props.navbar.confirmCreateResource("MyCustomRubrics"); + navbar.confirmCreateResource("MyCustomRubrics"); }; } handleCategorySelect = (categoryId, isSelected) => { - const selectedCategories = { ...this.state.selectedCategories }; - - if (isSelected) - selectedCategories[categoryId] = true; - - else - delete selectedCategories[categoryId]; - + var allCategories = this.state.allCategories; + var selectedCategories = this.state.categories; + + if (isSelected) { + const correctCategory = allCategories.find(category => category.category_id === categoryId) + selectedCategories.push(correctCategory); + } else { + selectedCategories = selectedCategories.filter(category => category.category_id !== categoryId); + } this.setState({ - selectedCategories: selectedCategories + categories: selectedCategories }); }; - render() { - const { rubrics, categories } = this.props; + componentDidMount() { + var navbar = this.props.navbar; + var addCustomRubric = navbar.state.addCustomRubric; + + this.setState({ + addCustomRubric: addCustomRubric + }); - const { selectedCategories, isHelpOpen, errors } = this.state; + var rubricId = navbar.rubricId; + if (addCustomRubric === false) { + genericResourceGET(`/category?rubric_id=${rubricId}`, "categories", this); + + genericResourceGET(`/rubric?rubric_id=${rubricId}`, "rubrics", this); + } + } + + render() { + const { categories, isLoaded, isHelpOpen, errors, errorMessage, addCustomRubric, defaultRubrics, allCategories, rubrics } = this.state; const categoryTableColumns = [ { @@ -157,18 +194,34 @@ class AddCustomRubric extends React.Component { viewColumns: false, }; - var pickedCategories = []; + if (errorMessage){ + return( +
+ +
+ ) + } + + else if (addCustomRubric===false) { + if (!isLoaded || !allCategories || !categories || !rubrics){ + return ( + + ) + } + } - Object.keys(selectedCategories).map((categoryId) => { - if (selectedCategories[categoryId]) { - for (var i = 0; i < categories.length; i++) { - if (categories[i]["category_id"] === categoryId - "0") { - pickedCategories = [...pickedCategories, categories[i]]; + var pickedCategories = []; + categories.forEach((category) => { + if (category) { + for (let i = 0; i < allCategories.length; i++) { + if (allCategories[i]["category_id"] === category["category_id"]) { + pickedCategories.push(allCategories[i]); } } } - - return categoryId; }); return ( @@ -189,20 +242,35 @@ class AddCustomRubric extends React.Component { bold: true, }} aria-label="addCustomizeYourRubricTitle" - > - Customize Your Rubric + > {this.state.addCustomRubric ? "Customize Your Rubric" : "Edit Your Rubric" } - - { - this.handleCreateRubric(pickedCategories); - }} - /> + + {!this.state.addCustomRubric && ( + + { + this.handleDeleteRubric(rubrics.rubric_id); + }} + style={{ marginRight: "16px" }} + /> + + )} + + + { + this.handleCreateRubric(pickedCategories); + }} + /> + @@ -220,6 +288,7 @@ class AddCustomRubric extends React.Component { diff --git a/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AdminEditCustomRubric.js b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AdminEditCustomRubric.js new file mode 100644 index 000000000..c873d08f8 --- /dev/null +++ b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/AdminEditCustomRubric.js @@ -0,0 +1,56 @@ +import React, { Component } from "react"; +import "bootstrap/dist/css/bootstrap.css"; +import ErrorMessage from "../../../Error/ErrorMessage"; +import { genericResourceGET, parseCategoriesToContained, parseCategoryIDToCategories, } from "../../../../utility.js"; +import EditCustomRubric from "./EditCustomRubric"; +import Loading from "../../../Loading/Loading.js"; + +class AdminEditCustomRubric extends Component { + constructor(props) { + super(props); + + this.state = { + isLoaded: null, + errorMessage: null, + rubrics: null, + categories: null, + }; + } + + componentDidMount() { + genericResourceGET(`/rubric`, "rubrics", this); + + genericResourceGET(`/category`, "categories", this); + } + + render() { + const { isLoaded, errorMessage, rubrics, categories } = this.state; + + if (errorMessage) { + return ( +
+ +
+ ); + } else if (!isLoaded || !rubrics || !categories) { + return ( + + ); + } else { + return ( + + ); + } + } +} + +export default AdminEditCustomRubric; diff --git a/FrontEndReact/src/View/Admin/Add/AddCustomRubric/CollapsableRubricCategoryTable.js b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/CollapsableRubricCategoryTable.js index c2e5ca56d..3b06f29d7 100644 --- a/FrontEndReact/src/View/Admin/Add/AddCustomRubric/CollapsableRubricCategoryTable.js +++ b/FrontEndReact/src/View/Admin/Add/AddCustomRubric/CollapsableRubricCategoryTable.js @@ -2,7 +2,7 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Check import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; import { createTheme, ThemeProvider } from '@mui/material/styles'; import React, { useState } from "react"; - +import Button from "@mui/material/Button"; // NOTE: Custom Theme for the Collapsible table @@ -39,7 +39,7 @@ const customTheme = createTheme({ }, }); -const CollapsableRubricCategoryTable = ({ categories, rubrics, onCategorySelect, readOnly }) => { +const CollapsableRubricCategoryTable = ({ categories, rubrics, onCategorySelect, readOnly, showEditButton, selectedCategories, navbar }) => { // NOTE: Manage whether the rubric was clicked or not const [openRubric, setOpenRubric] = useState(null); @@ -47,9 +47,12 @@ const CollapsableRubricCategoryTable = ({ categories, rubrics, onCategorySelect, const handleRubricClick = (rubricId) => { setOpenRubric(openRubric === rubricId ? null : rubricId); }; - + // NOTE: Manage whether the category was clicked or not - const [checkedCategories, setCheckedCategories] = useState([]); + const [checkedCategories, setCheckedCategories] = useState( + readOnly ? [] : selectedCategories.map(category => category.category_id) + ); + const handleCheckboxChange = (categoryId) => { const isChecked = checkedCategories.includes(categoryId); @@ -95,6 +98,24 @@ const CollapsableRubricCategoryTable = ({ categories, rubrics, onCategorySelect, ) : ( )} + {showEditButton && ( + + )} diff --git a/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js b/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js index 808312d0a..0e99d2c47 100644 --- a/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js +++ b/FrontEndReact/src/View/Admin/Add/AddTask/AdminAddAssessmentTask.js @@ -30,6 +30,7 @@ class AdminAddAssessmentTask extends Component { password: '', notes: '', numberOfTeams: null, + maxTeamSize: null, suggestions: true, ratings: true, usingTeams: false, @@ -40,6 +41,7 @@ class AdminAddAssessmentTask extends Component { taskName: '', timeZone: '', numberOfTeams: '', + maxTeamSize: '', roleId: '', rubricId: '', password: '', @@ -68,6 +70,7 @@ class AdminAddAssessmentTask extends Component { } componentDidMount() { + var navbar = this.props.navbar; var state = navbar.state; var assessmentTask = state.assessmentTask; @@ -76,7 +79,10 @@ class AdminAddAssessmentTask extends Component { if (assessmentTask && !addAssessmentTask) { genericResourceGET( `/completed_assessment?assessment_task_id=${assessmentTask["assessment_task_id"]}`, - "completedAssessments", this); + "completed_assessments", + this, + { dest: "completedAssessments" } + ); this.setState({ taskName: assessmentTask["assessment_task_name"], @@ -90,22 +96,35 @@ class AdminAddAssessmentTask extends Component { usingTeams: assessmentTask["unit_of_assessment"], dueDate: new Date(assessmentTask["due_date"]), editAssessmentTask: true, - numberOfTeams: assessmentTask["number_of_teams"] + numberOfTeams: assessmentTask["number_of_teams"], + maxTeamSize: assessmentTask["max_team_size"] }); } } handleChange = (e) => { const { id, value } = e.target; + const regex = /^[1-9]\d*$/; // Positive digits if (id === 'numberOfTeams') { - const regex = /^[1-9]\d*$/; if (value !== '' && !regex.test(value)) { this.setState({ errors: { ...this.state.errors, [id]: 'Number of teams must be greater than zero', - }, + } + }); + return; + } + } + + if (id === 'maxTeamSize') { + if (value !== '' && !regex.test(value)) { + this.setState({ + errors: { + ...this.state.errors, + [id]: 'Number of members on a team must be greater than zero', + } }); return; } @@ -146,7 +165,8 @@ class AdminAddAssessmentTask extends Component { suggestions, ratings, usingTeams, - numberOfTeams + numberOfTeams, + maxTeamSize } = this.state; var navbar = this.props.navbar; @@ -166,6 +186,15 @@ class AdminAddAssessmentTask extends Component { return; } + if (!maxTeamSize || !/^[1-9]\d*$/.test(maxTeamSize)) { + this.setState({ + errors: { + ...this.state.errors, + maxTeamSize: 'Number of members on a team must be greater than zero' + } + }); + return; + } } if (taskName === '' || timeZone === '' || roleId === '' || rubricId === '' || notes === '') { this.setState({ @@ -192,31 +221,33 @@ class AdminAddAssessmentTask extends Component { "unit_of_assessment": usingTeams, "create_team_password": password, "comment": notes, - "number_of_teams": numberOfTeams + "number_of_teams": numberOfTeams, + "max_team_size": maxTeamSize }); + + let promise; if (navbar.state.addAssessmentTask) { - genericResourcePOST( + promise = genericResourcePOST( "/assessment_task", this, body ); } else { - genericResourcePUT( + promise = genericResourcePUT( `/assessment_task?assessment_task_id=${assessmentTask["assessment_task_id"]}`, this, body ); } - confirmCreateResource("AssessmentTask"); + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + confirmCreateResource("AssessmentTask"); + } + }); } }; - hasErrors = () => { - const { errors } = this.state; - - return Object.values(errors).some((error) => !!error); - }; render() { var navbar = this.props.navbar; @@ -370,6 +401,26 @@ class AdminAddAssessmentTask extends Component { /> } + {usingTeams && !chosenCourse["use_fixed_teams"] && + + } + Completed By @@ -458,11 +509,19 @@ class AdminAddAssessmentTask extends Component { EST + EDT + CST + CDT + MST + MDT + PST + + PDT {errors.timeZone} diff --git a/FrontEndReact/src/View/Admin/Add/AddTeam/AdminAddTeam.js b/FrontEndReact/src/View/Admin/Add/AddTeam/AdminAddTeam.js index a3e22b054..ec3d49f48 100644 --- a/FrontEndReact/src/View/Admin/Add/AddTeam/AdminAddTeam.js +++ b/FrontEndReact/src/View/Admin/Add/AddTeam/AdminAddTeam.js @@ -7,7 +7,7 @@ import { genericResourcePOST, genericResourcePUT, genericResourceGET } from "../ import { FormControl, MenuItem, InputLabel, Select } from "@mui/material"; import Cookies from 'universal-cookie'; import FormHelperText from '@mui/material/FormHelperText'; - +import Loading from "../../../Loading/Loading.js"; class AdminAddTeam extends Component { @@ -110,13 +110,19 @@ class AdminAddTeam extends Component { active_until: null, }); + let promise; + if (team === null && addTeam === null) { - genericResourcePOST(`/team?course_id=${chosenCourse.course_id}`, this, body); + promise = genericResourcePOST(`/team?course_id=${chosenCourse.course_id}`, this, body); } else if (team !== null && addTeam === false) { - genericResourcePUT(`/team?team_id=${team.team_id}`, this, body); + promise = genericResourcePUT(`/team?team_id=${team.team_id}`, this, body); } - - confirmCreateResource("Team"); + + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + confirmCreateResource("Team"); + } + }); } }; @@ -144,6 +150,11 @@ class AdminAddTeam extends Component { var instructors = []; if (this.state.isLoaded){ + if (this.state.users === null) { + return ( + + ); + } instructors = this.state.users.map((item) => { return { id: item["user_id"], diff --git a/FrontEndReact/src/View/Admin/Add/AddTeam/AdminEditTeamMembers.js b/FrontEndReact/src/View/Admin/Add/AddTeam/AdminEditTeamMembers.js index 9643e41a1..6cd7d125e 100644 --- a/FrontEndReact/src/View/Admin/Add/AddTeam/AdminEditTeamMembers.js +++ b/FrontEndReact/src/View/Admin/Add/AddTeam/AdminEditTeamMembers.js @@ -55,16 +55,20 @@ class AdminEditTeamMembers extends Component { var team = state.team; var url = `/user?team_id=${team["team_id"]}&user_ids=${users}`; + + let promise; if (this.props.addTeamAction === "Add") { - genericResourcePOST(url, this, users); + promise = genericResourcePOST(url, this, users); } else { - genericResourcePUT(url, this, users); + promise = genericResourcePUT(url, this, users); } - setTimeout(() => { - confirmCreateResource("TeamMembers"); - }, 1000); + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + confirmCreateResource("TeamMembers"); + } + }); }; } diff --git a/FrontEndReact/src/View/Admin/Add/AddUsers/AdminAddUser.js b/FrontEndReact/src/View/Admin/Add/AddUsers/AdminAddUser.js index 7f51170f2..eb7e009d4 100644 --- a/FrontEndReact/src/View/Admin/Add/AddUsers/AdminAddUser.js +++ b/FrontEndReact/src/View/Admin/Add/AddUsers/AdminAddUser.js @@ -2,8 +2,9 @@ import React, { Component } from 'react'; import 'bootstrap/dist/css/bootstrap.css'; import validator from "validator"; import ErrorMessage from '../../../Error/ErrorMessage.js'; -import ResponsiveDialog from '../../../Components/DropConfirmation.js'; -import { genericResourcePOST, genericResourcePUT } from '../../../../utility.js'; +import DropConfirmation from '../../../Components/DropConfirmation.js'; +import DeleteConfirmation from '../../../Components/DeleteConfirmation.js'; +import { genericResourceDELETE, genericResourcePOST, genericResourcePUT } from '../../../../utility.js'; import { Box, Button, FormControl, Typography, TextField, MenuItem, InputLabel, Select} from '@mui/material'; import Cookies from 'universal-cookie'; import FormHelperText from '@mui/material/FormHelperText'; @@ -19,6 +20,7 @@ class AdminAddUser extends Component { validMessage: "", editUser: false, showDialog: false, + mode: "", firstName: '', lastName: '', @@ -45,6 +47,19 @@ class AdminAddUser extends Component { userId: navbar.state.user["user_id"], courseId: navbar.state.chosenCourse["course_id"] } + ).then(result => { + if (result !== undefined && result.errorMessage === null) { + navbar.confirmCreateResource("User"); + } + }); + } + + this.deleteUser = () => { + var navbar = this.props.navbar; + + genericResourceDELETE( + `/user?uid=${navbar.state.user["user_id"]}`, + this ); navbar.confirmCreateResource("User"); @@ -69,11 +84,26 @@ class AdminAddUser extends Component { } } - handleDialog = () => { this.setState({ + mode : "", showDialog: this.state.showDialog === false ? true : false, }) + + } + + handleDrop = () => { + this.handleDialog(); + this.setState({ + mode: "drop", + }) + } + + handleDelete = () => { + this.handleDialog(); + this.setState({ + mode: "delete", + }) } handleChange = (e) => { @@ -164,25 +194,31 @@ class AdminAddUser extends Component { "role_id": navbar.props.isSuperAdmin ? 3 : role }); + let promise; + if(user === null && addUser === false) { if(navbar.props.isSuperAdmin) { - genericResourcePOST(`/user`, this, body); + promise = genericResourcePOST(`/user`, this, body); } else { - genericResourcePOST(`/user?course_id=${chosenCourse["course_id"]}`, this, body); + promise = genericResourcePOST(`/user?course_id=${chosenCourse["course_id"]}`, this, body); } } else if (user === null && addUser === true && navbar.props.isSuperAdmin) { - genericResourcePOST(`/user`, this, body); + promise = genericResourcePOST(`/user`, this, body); } else if (user !== null && addUser === false && navbar.props.isSuperAdmin) { - genericResourcePUT(`/user?uid=${user["user_id"]}`, this, body); + promise = genericResourcePUT(`/user?uid=${user["user_id"]}`, this, body); } else { - genericResourcePUT(`/user?uid=${user["user_id"]}&course_id=${chosenCourse["course_id"]}`, this, body); + promise = genericResourcePUT(`/user?uid=${user["user_id"]}&course_id=${chosenCourse["course_id"]}`, this, body); } - confirmCreateResource("User"); + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + confirmCreateResource("User"); + } + }); } hasErrors = () => { @@ -227,8 +263,8 @@ class AdminAddUser extends Component { } - + @@ -248,11 +295,18 @@ class AdminAddUser extends Component { { !navbar.props.isSuperAdmin && state.user !== null && state.addUser === false && - } + { navbar.props.isSuperAdmin && state.user !== null && state.addUser === false && + + + + } diff --git a/FrontEndReact/src/View/Admin/Add/AddUsers/AdminBulkUpload.js b/FrontEndReact/src/View/Admin/Add/AddUsers/AdminBulkUpload.js index 2bd72586b..07a169401 100644 --- a/FrontEndReact/src/View/Admin/Add/AddUsers/AdminBulkUpload.js +++ b/FrontEndReact/src/View/Admin/Add/AddUsers/AdminBulkUpload.js @@ -117,13 +117,7 @@ class AdminBulkUpload extends Component { errorMessage: null, isLoaded: false }); - }, 2000); - } - - if (this.state.errorMessage === null && this.state.isLoaded === true) { - setTimeout(() => { - this.props.navbar.setNewTab(this.props.tab === "BulkUpload" ? "Users" : "Teams"); - }, 1000); + }, 5000); } } diff --git a/FrontEndReact/src/View/Admin/Add/ImportTasks/AdminImportAssessmentTasks.js b/FrontEndReact/src/View/Admin/Add/ImportTasks/AdminImportAssessmentTasks.js index fde70d387..7059901de 100644 --- a/FrontEndReact/src/View/Admin/Add/ImportTasks/AdminImportAssessmentTasks.js +++ b/FrontEndReact/src/View/Admin/Add/ImportTasks/AdminImportAssessmentTasks.js @@ -48,11 +48,11 @@ class AdminImportAssessmentTask extends Component { } genericResourcePOST( - `/assessment_task_copy?source_course_id=${selectedCourse}&destination_course_id=${chosenCourse["course_id"]}`, - this, {} - ); - - navbar.confirmCreateResource("AssessmentTask"); + `/assessment_task_copy?source_course_id=${selectedCourse}&destination_course_id=${chosenCourse["course_id"]}`,this, {}).then((result) => { + if (result !== undefined && result.errorMessage === null) { + navbar.confirmCreateResource("AssessmentTask"); + } + }); } } diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/CompleteAssessmentTask.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/CompleteAssessmentTask.js index f01fe01b2..991c3ce07 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/CompleteAssessmentTask.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/CompleteAssessmentTask.js @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import 'bootstrap/dist/css/bootstrap.css'; import Form from "./Form.js"; -import { genericResourceGET } from '../../../../utility.js'; +import { genericResourceGET, createEventSource } from '../../../../utility.js'; import { Box } from '@mui/material'; import ErrorMessage from '../../../Error/ErrorMessage.js'; import Cookies from 'universal-cookie'; @@ -24,7 +24,8 @@ class CompleteAssessmentTask extends Component { roles: null, completedAssessments: null, checkin: null, - userId: null + userId: null, + checkinEventSource: null, } this.doRubricsForCompletedMatch = (newCompleted, storedCompleted) => { var newCompletedCategories = Object.keys(newCompleted).sort(); @@ -58,28 +59,21 @@ class CompleteAssessmentTask extends Component { this.handleDone = () => { var navbar = this.props.navbar; - let chosenAssessmentTask; + let chosenAssessmentTask = null; - if (navbar.state.chosenCompleteAssessmentTask !== null) { + if (navbar.state.chosenCompleteAssessmentTask && navbar.state.chosenCompleteAssessmentTask.assessment_task_id) { chosenAssessmentTask = navbar.state.chosenCompleteAssessmentTask; - } else { + } else if(navbar.state.chosenAssessmentTask && navbar.state.chosenAssessmentTask.assessment_task_id) { chosenAssessmentTask = navbar.state.chosenAssessmentTask; } + if(!chosenAssessmentTask) { + return; + } + genericResourceGET( `/completed_assessment?assessment_task_id=${chosenAssessmentTask["assessment_task_id"]}&unit=${this.state.unitOfAssessment ? "team" : "individual"}`, - "completedAssessments", this - ); - } - - this.refreshUnits = () => { - var navbar = this.props.navbar; - - var chosenAssessmentTask = navbar.state.chosenCompleteAssessmentTask; - - genericResourceGET( - `/checkin?assessment_task_id=${chosenAssessmentTask["assessment_task_id"]}`, - "checkin", this + "completed_assessments", this, {dest: "completedAssessments"} ); } } @@ -138,15 +132,10 @@ class CompleteAssessmentTask extends Component { if (chosenAssessmentTask["role_id"] === 5) { genericResourceGET( `/team_by_user?user_id=${this.userId}&course_id=${chosenCourse["course_id"]}`, - "team", this + "teams", this, {dest: "team"} ); } - genericResourceGET( - `/checkin?assessment_task_id=${chosenAssessmentTask["assessment_task_id"]}`, - "checkin", this - ); - genericResourceGET( `/team?course_id=${chosenCourse["course_id"]}`, "teams", this @@ -168,9 +157,25 @@ class CompleteAssessmentTask extends Component { genericResourceGET( `/completed_assessment?assessment_task_id=${chosenAssessmentTask["assessment_task_id"]}&unit=${this.state.unitOfAssessment ? "team" : "individual"}`, - "completedAssessments", this + "completed_assessments", this, {dest: "completedAssessments"} ); - + + const checkinEventSource = createEventSource( + `/checkin_events?assessment_task_id=${chosenAssessmentTask["assessment_task_id"]}`, + ({data}) => { + this.setState({ + checkin: JSON.parse(data), + }); + } + ); + + this.setState({ + checkinEventSource: checkinEventSource, + }); + } + + componentWillUnmount() { + this.state.checkinEventSource?.close(); } render() { @@ -190,6 +195,8 @@ class CompleteAssessmentTask extends Component { var navbar = this.props.navbar; + const fixedTeams = navbar.state.chosenCourse["use_fixed_teams"]; + var chosenAssessmentTask = navbar.state.chosenAssessmentTask; if (errorMessage) { @@ -205,18 +212,23 @@ class CompleteAssessmentTask extends Component { ); - } else if (chosenAssessmentTask["unit_of_assessment"] && teams.length === 0) { + } else if (chosenAssessmentTask["unit_of_assessment"] && (fixedTeams && teams.length === 0)) { return ( -

Please create a team to complete this assessment for.

+

Please create a team to complete this assessment.

) } else if (!chosenAssessmentTask["unit_of_assessment"] && users.length === 0) { return ( -

Please add students to the roster to complete this assessment for.

+

Please add students to the roster to complete this assessment.

) } var role_name=roles["role_name"] + if (role_name === "Student" && this.state.unitOfAssessment && !team){ + return ( + + ); + } if (role_name !== "Student" && this.state.unitOfAssessment && !teams_users) { return ( @@ -345,8 +357,6 @@ class CompleteAssessmentTask extends Component { handleDone={this.handleDone} - refreshUnits={this.refreshUnits} - completedAssessments={completedAssessments} />
diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js index da6595c8b..30dbd7ce6 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Form.js @@ -4,7 +4,6 @@ import '../../../../SBStyles.css'; import Section from './Section.js'; import { Box, Tab, Button } from '@mui/material'; import Tabs, { tabsClasses } from '@mui/material/Tabs'; -import RefreshIcon from '@mui/icons-material/Refresh'; import UnitOfAssessmentTab from './UnitOfAssessmentTab.js'; import StatusIndicator from './StatusIndicator.js'; import { genericResourcePOST, genericResourcePUT } from '../../../../utility.js'; @@ -52,12 +51,10 @@ class Form extends Component { //TODO: fix in the case that chosenCompleteAssessmentTask is null this.generateCategoriesAndSection ); - console.log("handleUnitTabChange chosenCompleteAssessmentTask", this.state.chosenCompleteAssessmentTask) }; this.handleChange = (event, newValue) => { - console.log("newValue", newValue) this.setState({ value: newValue, }, @@ -162,18 +159,18 @@ class Form extends Component { var category = unit[categoryName]; - var observableCharacteristic = category["observable_characteristics"].includes("1"); + var observableCharacteristic = category["observable_characteristics"].includes("1"); const showSuggestions = this.props.navbar.state.chosenAssessmentTask["show_suggestions"]; const suggestions = showSuggestions ? category["suggestions"].includes("1") : false; - var status = false; + let status = null; // null is for not filled out at all (grey empty circle on category tab) if (observableCharacteristic && (!showSuggestions || suggestions)) { - status = true; + status = true; // true is for fully filled out (green filled in circle on category tab) } else if (observableCharacteristic || suggestions) { - status = false; + status = false; // false is for partially filled out (yellow half filled in circle on category tab) } return status; @@ -275,7 +272,7 @@ class Form extends Component { var navbar = this.props.navbar; var state = navbar.state; -console.log("state", state) + var chosenAssessmentTask = state.chosenAssessmentTask; var chosenCompleteAssessmentTask = state.chosenCompleteAssessmentTask; @@ -285,14 +282,12 @@ console.log("state", state) var selected = this.state.unitData[currentUnitTab]; var date = new Date(); -console.log("before the if chosenCompleteAssessmentTask", chosenCompleteAssessmentTask) if(chosenCompleteAssessmentTask) { chosenCompleteAssessmentTask["rating_observable_characteristics_suggestions_data"] = selected; chosenCompleteAssessmentTask["last_update"] = date; chosenCompleteAssessmentTask["done"] = done; -console.log("chosenCompleteAssessmentTask", this.state.chosenCompleteAssessmentTask) genericResourcePUT( `/completed_assessment?completed_assessment_id=${chosenCompleteAssessmentTask["completed_assessment_id"]}`, this, @@ -339,13 +334,10 @@ console.log("chosenCompleteAssessmentTask", this.state.chosenCompleteAssessmentT done: done, }; } - console.log("chosenCompleteAssessmentTask", chosenCompleteAssessmentTask, "userRole", this.props.userRole) if (chosenCompleteAssessmentTask && this.props.userRole) { - console.log("PUT") genericResourcePUT(route, this, JSON.stringify(assessmentData)); } else { - console.log("POST") genericResourcePOST(route, this, JSON.stringify(assessmentData)); } } @@ -401,34 +393,22 @@ console.log("chosenCompleteAssessmentTask", this.state.chosenCompleteAssessmentT Assessment Saved! } - - - + { !this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly && + + }
@@ -470,7 +450,15 @@ console.log("chosenCompleteAssessmentTask", this.state.chosenCompleteAssessmentT [`& .MuiTabs-indicator`]: { display: 'none' - } + }, + + '& .MuiTab-root': { + border: '2px solid', + '&.Mui-selected': { + backgroundColor: '#D9D9D9', + color: 'inherit', + } + }, }} > {this.state.categoryList} diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/ObservableCharacteristic.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/ObservableCharacteristic.js index 1deca27eb..279b1990b 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/ObservableCharacteristic.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/ObservableCharacteristic.js @@ -23,6 +23,8 @@ class ObservableCharacteristic extends Component { render() { const handleChange = () => { + if (this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly) return; + this.setState((prevState) => ({ checked: !prevState.checked, })); @@ -50,7 +52,7 @@ class ObservableCharacteristic extends Component { onClick={handleChange} - disabled={this.props.isUnitCompleteAssessmentComplete(this.props.unitValue) && !this.props.navbar.props.isAdmin} + disabled={this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly} > diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Rating.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Rating.js index cc803dd15..b61507f22 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Rating.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Rating.js @@ -63,7 +63,7 @@ class Rating extends Component { justifyContent:'center' }} - disabled={this.props.isUnitCompleteAssessmentComplete(this.props.unitValue)} + disabled={this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly} > { - if(this.props.isUnitCompleteAssessmentComplete(this.props.unitValue)) return; + if(this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly) return; setSliderValue( this.props.unitValue, @@ -116,7 +116,7 @@ class Rating extends Component { this.props.autosave(); }} - disabled={this.props.isUnitCompleteAssessmentComplete(this.props.unitValue)} + disabled={this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly} /> ) diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js index 232a1fa05..c93231789 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Section.js @@ -16,7 +16,9 @@ class Section extends Component { super(props); this.autosave = debounce(() => { - this.props.handleSubmit(false); + const done = this.props.currentData?.done === true; + + this.props.handleSubmit(done); }, 2000); } diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Suggestion.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Suggestion.js index 668ec1189..489f06ed3 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Suggestion.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/Suggestion.js @@ -23,6 +23,8 @@ class Suggestion extends Component { render() { const handleChange = () => { + if (this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly) return; + this.setState((prevState) => ({ checked: !prevState.checked, })); @@ -50,7 +52,7 @@ class Suggestion extends Component { onClick={handleChange} - disabled={this.props.isUnitCompleteAssessmentComplete(this.props.unitValue) && !this.props.navbar.props.isAdmin} + disabled={this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly} > diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/TextArea.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/TextArea.js index 9fecf5a2a..26c17cb34 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/TextArea.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/TextArea.js @@ -14,7 +14,7 @@ class TextArea extends Component { } handleTextareaChange = (event) => { - if(this.props.isUnitCompleteAssessmentComplete(this.props.unitValue) && !this.props.navbar.props.isAdmin) return; + if (this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly) return; const { unitValue, categoryName, setComments } = this.props; @@ -53,7 +53,7 @@ class TextArea extends Component { onChange={this.handleTextareaChange} - disabled={this.props.isUnitCompleteAssessmentComplete(this.props.unitValue) && !this.props.navbar.props.isAdmin} + disabled={this.props.navbar.state.chosenCompleteAssessmentTaskIsReadOnly} />
); diff --git a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/UnitOfAssessmentTab.js b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/UnitOfAssessmentTab.js index 29a1b83d6..6e8e8d43c 100644 --- a/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/UnitOfAssessmentTab.js +++ b/FrontEndReact/src/View/Admin/View/CompleteAssessmentTask/UnitOfAssessmentTab.js @@ -95,21 +95,34 @@ class UnitOfAssessmentTab extends Component { { this.props.handleUnitChange(event, newValue); this.props.handleUnitTabChange(newValue); }} + variant="scrollable" scrollButtons aria-label="visible arrows tabs example" + sx={{ width: "100%", + [`& .${tabsClasses.scrollButtons}`]: { '&.Mui-disabled': { opacity: 0.3 }, }, + [`& .MuiTabs-indicator`]: { display: 'none' }, + + '& .MuiTab-root': { + border: '2px solid', + '&.Mui-selected': { + backgroundColor: '#D9D9D9', + color: 'inherit', + } + }, }} > {unitList} diff --git a/FrontEndReact/src/View/Admin/View/Reporting/ReportingDashboard.js b/FrontEndReact/src/View/Admin/View/Reporting/ReportingDashboard.js index ecedda777..b89711aa8 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/ReportingDashboard.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/ReportingDashboard.js @@ -22,7 +22,7 @@ class ReportingDashboard extends Component { componentDidMount() { var courseID = this.props.navbar.state.chosenCourse.course_id; - genericResourceGET(`/assessment_task?course_id=${courseID}`, "assessmentTasks", this); + genericResourceGET(`/assessment_task?course_id=${courseID}`, "assessment_tasks", this, {dest: "assessmentTasks"}); } diff --git a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/AdminViewAssessmentStatus.js b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/AdminViewAssessmentStatus.js index 7efd6fc3d..a2bbeaa87 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/AdminViewAssessmentStatus.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/AdminViewAssessmentStatus.js @@ -21,6 +21,7 @@ class AdminViewAssessmentStatus extends Component { showRatings: true, showSuggestions: true, completedByTAs: true, + completedAssessmentsPercentage: null, } this.fetchData = () => { @@ -30,7 +31,7 @@ class AdminViewAssessmentStatus extends Component { // Fetch completed assessment tasks data for the chosen assessment task genericResourceGET( `/completed_assessment?admin_id=${chosenCourse["admin_id"]}&assessment_task_id=${this.props.chosenAssessmentId}`, - "completedAssessments", this + "completed_assessments", this, {dest: "completedAssessments"} ); } @@ -61,6 +62,12 @@ class AdminViewAssessmentStatus extends Component { `/category?admin_id=${chosenCourse["admin_id"]}&rubric_id=${rubricId}`, "categories", this ); + + // Fetch ratio of users who have completed assessment task to total users in the class + genericResourceGET( + `/completed_assessment?course_id=${chosenCourse.course_id}&assessment_id=${this.props.chosenAssessmentId}`, + "completed_assessments", this, {dest: "completedAssessmentsPercentage"} + ); this.setState({ loadedAssessmentId: this.props.chosenAssessmentId, @@ -91,6 +98,7 @@ class AdminViewAssessmentStatus extends Component { showRatings, showSuggestions, completedByTAs, + completedAssessmentsPercentage, } = this.state; if(errorMessage) { @@ -111,6 +119,7 @@ class AdminViewAssessmentStatus extends Component { return(
) diff --git a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/CharacteristicsAndImprovements.js b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/CharacteristicsAndImprovements.js index 250056831..900eb7bb6 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/CharacteristicsAndImprovements.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/CharacteristicsAndImprovements.js @@ -1,6 +1,28 @@ import React from 'react'; -import { BarChart, CartesianGrid, XAxis, YAxis, Bar, LabelList } from 'recharts'; -import "bootstrap/dist/css/bootstrap.css"; +import { BarChart, CartesianGrid, XAxis, YAxis, Bar, LabelList, ResponsiveContainer, Tooltip } from 'recharts'; + +const truncateText = (text, limit = 15) => { + if (text.length <= limit) return text; + return `${text.substring(0, limit)}...`; +}; + +const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + const fullText = payload[0].payload[payload[0].payload.characteristic ? 'characteristic' : 'improvement']; + return ( +
+
+
+
+

{fullText}

+
+
+
+
+ ); + } + return null; +}; export default function CharacteristicsAndImprovements({ dataType, @@ -11,49 +33,78 @@ export default function CharacteristicsAndImprovements({ const data = dataType === 'characteristics' ? characteristicsData.characteristics : improvementsData.improvements; - const dataKey = dataType === 'characteristics' ? 'characteristic' : 'improvement'; - const chartHeight = 210; - const chartWidth = 650; + + const processedData = data.map(item => ({ + ...item, + truncatedLabel: truncateText(item[dataType === 'characteristics' ? 'characteristic' : 'improvement']) + })); const shouldShowGraph = dataType === 'characteristics' || showSuggestions; return ( -
-
- {dataType === 'characteristics' ? 'Characteristics' : 'Improvements'} -
-
- {shouldShowGraph ? ( - - - - - - - - - ) : ( -
- - - +
+
+
+
+
+
+ {dataType === 'characteristics' ? 'Characteristics' : 'Improvements'} +
+ +
+ {shouldShowGraph ? ( + + + + + + } + cursor={{ fill: 'rgba(46, 139, 239, 0.1)' }} + /> + + `${value}`} + /> + + + + ) : ( +
+ + + +
+ )} +
+
- )} +
); diff --git a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/ViewAssessmentStatus.js b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/ViewAssessmentStatus.js index b76404908..4a1813da7 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/ViewAssessmentStatus.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/ViewAssessmentStatus/ViewAssessmentStatus.js @@ -3,7 +3,7 @@ import Box from '@mui/material/Box'; import { Container } from '@mui/material'; //import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; -import { BarChart, CartesianGrid, XAxis, YAxis, Bar, LabelList } from 'recharts'; +import { BarChart, CartesianGrid, XAxis, YAxis, Bar, LabelList, ResponsiveContainer } from 'recharts'; import AssessmentTaskDropdown from '../../../../Components/AssessmentTaskDropdown.js'; import CategoryDropdown from '../../../../Components/CategoryDropdown.js'; import CharacteristicsAndImprovements from './CharacteristicsAndImprovements.js'; @@ -65,7 +65,7 @@ export default function ViewAssessmentStatus(props) { var allRatings = []; var avg = 0; var stdev = 0; - var progress = 43; + var progress = props.completedAssessmentsPercentage; if (props.completedAssessments !== null && props.completedAssessments.length > 0) { // Iterate through each completed assessment for chosen assessment task @@ -111,14 +111,13 @@ export default function ViewAssessmentStatus(props) { characteristicsData['characteristics'][i]['percentage'] = percent + "%"; } - + for (let i = 0; i < improvementsData['improvements'].length; i++) { let percent = improvementsData['improvements'][i]['number'] / (props.completedAssessments !== null ? props.completedAssessments.length : 1) * 100; improvementsData['improvements'][i]['percentage'] = percent + "%"; } } -/*remove border color top and fix it*/ const innerGridStyle = { borderRadius: '1px', height: '100%', @@ -128,148 +127,137 @@ export default function ViewAssessmentStatus(props) { boxShadow: "0.3em 0.3em 1em #d6d6d6" }; - const outerQuadrantSX = { - display:"flex", - justifyContent:"center" - }; - const innerDivClassName = 'd-flex flex-column p-3 w-100 justify-content-center align-items-center'; return ( - - - -
- -
Distribution of Ratings
-
- {props.showRatings ? ( - - - - - - - - - ) : ( - - - + + +
+
+ Distribution of Ratings +
+
+ {props.showRatings ? ( + + + + + + + + + + + ) : ( +
+ + + +
+ )} +
+ {props.showRatings && ( +
+ Avg: {avg}; StdDev: {stdev} +
)}
- {props.showRatings && ( -
- Avg: {avg}; StdDev: {stdev} -
- )} -
- + -
- -
+ +
+
+ + + + -
-
-

- Assessment Tasks Completed: -

-
-
-
- {progress} - -
-
-
-
+ +
+
+

+ Assessment Tasks Completed: +

+
+
+ {progress >= 20 && ( +
+ {progress}% +
+ )} +
+ {progress < 20 && ( +
+ {progress}% +
+ )} +
+
- - -
- + + + + +
+
- -
- + +
+
- ) + ); } \ No newline at end of file diff --git a/FrontEndReact/src/View/Admin/View/Reporting/ViewRatings/AdminViewTeamRatings.js b/FrontEndReact/src/View/Admin/View/Reporting/ViewRatings/AdminViewTeamRatings.js index 89d317e37..982d20217 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/ViewRatings/AdminViewTeamRatings.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/ViewRatings/AdminViewTeamRatings.js @@ -21,7 +21,7 @@ class AdminViewTeamRatings extends Component { componentDidMount() { genericResourceGET( `/assessment_task?admin_id=${this.props.chosenCourse["admin_id"]}`, - "assessmentTasks", this + "assessment_tasks", this, {dest: "assessmentTasks"} ); } diff --git a/FrontEndReact/src/View/Admin/View/Reporting/__tests__/ReportingDashboard.test.js b/FrontEndReact/src/View/Admin/View/Reporting/__tests__/ReportingDashboard.test.js index 65ec1b539..1a09fe0cc 100644 --- a/FrontEndReact/src/View/Admin/View/Reporting/__tests__/ReportingDashboard.test.js +++ b/FrontEndReact/src/View/Admin/View/Reporting/__tests__/ReportingDashboard.test.js @@ -1,5 +1,6 @@ import { render, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; +import ResizeObserver from "resize-observer-polyfill"; import Login from "../../../../Login/Login.js"; import { @@ -13,7 +14,7 @@ import { demoAdminPassword, } from "../../../../../App.js"; - +global.ResizeObserver = ResizeObserver; var lf = "loginForm"; var lb = "loginButton"; @@ -27,15 +28,10 @@ var vasb = "viewAssessmentStatusBox"; var mhbb = "mainHeaderBackButton"; var raft = "ratingAndFeedbackTab"; var avrb = "adminViewRatingsBox"; -// var caiit = "characteristicsAndImprovementsImprovementTab"; -// var bcid = "barChartImprovementsData"; -var ast = "assessmentStatusTab"; -// var caict = "characteristicsAndImprovementsCharacteristicsTab"; -// var bccd = "barChartCharacteristicsData"; -test("NOTE: Tests 1-9 will not pass if Demo Data is not loaded!", () => { +test("NOTE: Tests 1-7 will not pass if Demo Data is not loaded!", () => { expect(true).toBe(true); }); @@ -98,7 +94,7 @@ test("ReportingDashboard.test.js Test 4: Should show Assessment Status page when }); -test("ReportingDashboard.test.js Test 5: Should show Roster Dashboard when clicking the back button", async () => { +test("ReportingDashboard.test.js Test 5: Should show Roster Dashboard when clicking the back button from the AssessmentStatus page.", async () => { render(); await waitFor(() => { @@ -107,7 +103,6 @@ test("ReportingDashboard.test.js Test 5: Should show Roster Dashboard when click clickFirstElementWithAriaLabel(vcib); }); - await waitFor(() => { expectElementWithAriaLabelToBeInDocument(rt); @@ -116,9 +111,9 @@ test("ReportingDashboard.test.js Test 5: Should show Roster Dashboard when click await waitFor(() => { expectElementWithAriaLabelToBeInDocument(vasb); - - clickElementWithAriaLabel(mhbb); }); + + clickElementWithAriaLabel(mhbb); await waitFor(() => { setTimeout(() => { @@ -155,95 +150,36 @@ test("ReportingDashboard.test.js Test 6: Should show Ratings and Feedback page w }); -test("ReportingDashboard.test.js Test 7: Should show Assessment Status page when clicking the assessment status tab after clicking the ratings and feedback tab", async () => { +test("ReportingDashboard.test.js Test 7: Should show Roster Dashboard when clicking the back button from the Ratings and Feedback page.", async () => { render(); await waitFor(() => { expectElementWithAriaLabelToBeInDocument(ct); - }); - clickFirstElementWithAriaLabel(vcib); + clickFirstElementWithAriaLabel(vcib); + }); await waitFor(() => { - expectElementWithAriaLabelToBeInDocument(rt); - }); + expectElementWithAriaLabelToBeInDocument(rt); - clickElementWithAriaLabel(rpt); + clickElementWithAriaLabel(rpt); + }); await waitFor(() => { expectElementWithAriaLabelToBeInDocument(vasb); - - clickElementWithAriaLabel(raft); }); + clickElementWithAriaLabel(raft); + await waitFor(() => { expectElementWithAriaLabelToBeInDocument(avrb); - - clickElementWithAriaLabel(ast); }); + clickElementWithAriaLabel(mhbb); + await waitFor(() => { - expectElementWithAriaLabelToBeInDocument(vasb); + setTimeout(() => { + expectElementWithAriaLabelToBeInDocument(rt); + }, 3000); }); -}); - - -// TODO: This test needs to be redone because ReportingDashboard was remade -// test("ReportingDashboard.test.js Test 8: Should show barchart with improvements data when clicking the improvements tab", async () => { -// render(); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(ct); -// }); - -// clickFirstElementWithAriaLabel(vcib); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(rt); -// }); - -// clickElementWithAriaLabel(rpt); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(vasb); - -// clickElementWithAriaLabel(caiit); -// }); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(bcid); -// }); -// }); - - -// test("ReportingDashboard.test.js Test 9: Should show barchart with characteristics data when clicking the characteristics tab after clicking the improvements tab", async () => { -// render(); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(ct); -// }); - -// clickFirstElementWithAriaLabel(vcib); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(rt); -// }); - -// clickElementWithAriaLabel(rpt); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(vasb); - -// clickElementWithAriaLabel(caiit); -// }); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(bcid); - -// clickElementWithAriaLabel(caict); -// }); - -// await waitFor(() => { -// expectElementWithAriaLabelToBeInDocument(bccd); -// }); -// }); \ No newline at end of file +}); \ No newline at end of file diff --git a/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/AdminViewAssessmentTask.js b/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/AdminViewAssessmentTask.js index bf3faa6b1..2d02105ec 100644 --- a/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/AdminViewAssessmentTask.js +++ b/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/AdminViewAssessmentTask.js @@ -34,7 +34,7 @@ class AdminViewAssessmentTask extends Component { genericResourceGET( `/assessment_task?course_id=${navbar.state.chosenCourse["course_id"]}`, - "assessmentTasks", this); + "assessment_tasks", this, {dest: "assessmentTasks"}); genericResourceGET(`/role?`,'roles', this); diff --git a/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/ViewAssessmentTasks.js b/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/ViewAssessmentTasks.js index 053aaa06a..02e3e0aad 100644 --- a/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/ViewAssessmentTasks.js +++ b/FrontEndReact/src/View/Admin/View/ViewAssessmentTask/ViewAssessmentTasks.js @@ -7,7 +7,7 @@ import { Tooltip } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import VisibilityIcon from '@mui/icons-material/Visibility'; import { formatDueDate, genericResourceGET, getHumanReadableDueDate } from '../../../../utility.js'; - +import Loading from '../../../Loading/Loading.js'; class ViewAssessmentTasks extends Component { @@ -19,49 +19,56 @@ class ViewAssessmentTasks extends Component { errorMessage: null, csvCreation: null, downloadedAssessment: null, - exportButtonId: {} + exportButtonId: {}, + completedAssessments: null, + assessmentTasks: null } this.handleDownloadCsv = (atId, exportButtonId, assessmentTaskIdToAssessmentTaskName) => { - genericResourceGET( - `/csv_assessment_export?assessment_task_id=${atId}`, - "csvCreation", - this + let promise = genericResourceGET( + `/csv_assessment_export?assessment_task_id=${atId}&format=1`, + "csv_creation", + this, + {dest: "csvCreation"} ); - var assessmentName = assessmentTaskIdToAssessmentTaskName[atId]; - - var newExportButtonJSON = this.state.exportButtonId; - - newExportButtonJSON[assessmentName] = exportButtonId; + promise.then(result => { + if (result !== undefined && result.errorMessage === null) { + var assessmentName = assessmentTaskIdToAssessmentTaskName[atId]; - this.setState({ - downloadedAssessment: assessmentName, - exportButtonId: newExportButtonJSON + var newExportButtonJSON = this.state.exportButtonId; + + newExportButtonJSON[assessmentName] = exportButtonId; + + this.setState({ + downloadedAssessment: assessmentName, + exportButtonId: newExportButtonJSON + }); } }); } } - componentDidUpdate() { + componentDidUpdate () { if(this.state.isLoaded && this.state.csvCreation) { const fileData = this.state.csvCreation["csv_data"]; - const blob = new Blob([fileData], { type: 'csv' }); - + const blob = new Blob([fileData], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); - link.download = this.state.downloadedAssessment + ".csv"; - link.href = url; - + link.setAttribute('download', this.props.navbar.state.chosenCourse['course_name']+'.csv'); link.click(); var assessmentName = this.state.downloadedAssessment; - + + const exportAssessmentTask = document.getElementById(this.state.exportButtonId[assessmentName]) + setTimeout(() => { - document.getElementById(this.state.exportButtonId[assessmentName]).removeAttribute("disabled"); + if(exportAssessmentTask) { + exportAssessmentTask.removeAttribute("disabled"); + } }, 10000); this.setState({ @@ -71,7 +78,31 @@ class ViewAssessmentTasks extends Component { } } + componentDidMount() { + const courseId = this.props.navbar.state.chosenCourse.course_id; + + genericResourceGET( + `/assessment_task?course_id=${courseId}`, + "assessment_tasks", + this, + {dest: "assessmentTasks"} + ); + + genericResourceGET( + `/completed_assessment?course_id=${courseId}&only_course=true`, + "completed_assessments", + this, + {dest: "completedAssessments"} + ); + } + render() { + + if (this.state.assessmentTasks === null || this.state.completedAssessments === null) { + return ; + } + const fixedTeams = this.props.navbar.state.chosenCourse["use_fixed_teams"]; + var navbar = this.props.navbar; var adminViewAssessmentTask = navbar.adminViewAssessmentTask; @@ -126,7 +157,7 @@ class ViewAssessmentTasks extends Component { customBodyRender: (assessmentTaskId) => { let dueDateString = getHumanReadableDueDate( assessmentTasksToDueDates[assessmentTaskId]["due_date"], - //assessmentTasksToDueDates[assessmentTaskId]["time_zone"] + assessmentTasksToDueDates[assessmentTaskId]["time_zone"] ); return( @@ -304,7 +335,7 @@ class ViewAssessmentTasks extends Component { const isTeamAssessment = assessmentTask && assessmentTask.unit_of_assessment; const teamsExist = this.props.teams && this.props.teams.length > 0; - if (isTeamAssessment && !teamsExist) { + if (isTeamAssessment && (fixedTeams && !teamsExist)) { return ( @@ -345,6 +376,26 @@ class ViewAssessmentTasks extends Component { setCellHeaderProps: () => { return { align:"center", width:"80px", className:"button-column-alignment"}}, setCellProps: () => { return { align:"center", width:"80px", className:"button-column-alignment"} }, customBodyRender: (atId) => { + const completedAssessments = this.state.completedAssessments.filter(ca => ca.assessment_task_id === atId); + const completedCount = completedAssessments.length > 0 ? completedAssessments[0].completed_count : 0; + + if (completedCount === 0) { + return ( + + + + + + ); + } return ( - - - - - - - - ) - } + } + setErrorMessage = (message) => { + this.setState({ errorMessage: message }); + // Clear error message after 3 seconds + setTimeout(() => { + this.setState({ errorMessage: null }); + }, 3000); + } + + setSuccessMessage = (message) => { + this.setState({ successMessage: message }); + // Clear success message after 3 seconds + setTimeout(() => { + this.setState({ successMessage: null }); + }, 3000); + } + render() { + const { errorMessage, isLoaded, teams, users, successMessage } = this.state; + + var navbar = this.props.navbar; + + navbar.adminViewTeams.teams = teams; + navbar.adminViewTeams.users = users ? parseUserNames(users) : []; + + var setNewTab = navbar.setNewTab; + var setAddTeamTabWithUsers = navbar.setAddTeamTabWithUsers; + + if (errorMessage) { + return ( +
+ +
+ ); + } else if (!isLoaded || !teams || !users) { + return ; + } else { + return ( + + {successMessage && ( +
+ +
+ )} + {errorMessage && ( +
+ +
+ )} + + + Teams + + + + + + + + + +
+ ); } + } } -export default AdminViewTeams; \ No newline at end of file +export default AdminViewTeams; diff --git a/FrontEndReact/src/View/Admin/View/ViewTeams/ViewTeams.js b/FrontEndReact/src/View/Admin/View/ViewTeams/ViewTeams.js index 0ba428df9..71dae9bf1 100644 --- a/FrontEndReact/src/View/Admin/View/ViewTeams/ViewTeams.js +++ b/FrontEndReact/src/View/Admin/View/ViewTeams/ViewTeams.js @@ -1,13 +1,36 @@ -import React, { Component } from "react" -import 'bootstrap/dist/css/bootstrap.css'; -import IconButton from '@mui/material/IconButton'; -import EditIcon from '@mui/icons-material/Edit'; -import VisibilityIcon from '@mui/icons-material/Visibility'; +import React, { Component } from "react"; +import "bootstrap/dist/css/bootstrap.css"; +import IconButton from "@mui/material/IconButton"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import CustomDataTable from "../../../Components/CustomDataTable.js"; +import { genericResourceDELETE } from "../../../../utility.js"; - - -class ViewTeams extends Component{ +class ViewTeams extends Component { + async deleteTeam(teamId) { + try { + const result = await genericResourceDELETE(`/team?team_id=${teamId}`, this, { + dest: "teams", + }); + if (result.errorMessage) { + throw new Error(result.errorMessage); + } + //window.alert("Team can be deleted") + this.props.onSuccess("Team deleted successfully"); + setTimeout(() => { + this.props.refreshData(); + }, 1000); + } catch (error) { + const errorMessage = error.message || "Cannot delete team. There are assessment task associated with this team."; + window.alert(errorMessage); + this.props.onError(errorMessage); + setTimeout(() => { + this.props.refreshData(); + }, 1000); + } + } + render() { var navbar = this.props.navbar; var adminViewTeams = navbar.adminViewTeams; @@ -23,59 +46,69 @@ class ViewTeams extends Component{ label: "Team Name", options: { filter: true, - setCellHeaderProps: () => { return { width:"20%"}}, - setCellProps: () => { return { width:"20%"} }, - } + setCellHeaderProps: () => { + return { width: "20%" }; + }, + setCellProps: () => { + return { width: "20%" }; + }, + }, }, { name: "observer_id", label: "Observer Name", options: { filter: true, - setCellHeaderProps: () => { return { width:"30%"}}, - setCellProps: () => { return { width:"30%"} }, + setCellHeaderProps: () => { + return { width: "30%" }; + }, + setCellProps: () => { + return { width: "30%" }; + }, customBodyRender: (observerId) => { - return( - observerId === chosenCourse["admin_id"] ? -

Admin

: + return observerId === chosenCourse["admin_id"] ? ( +

Admin

+ ) : (

{users[observerId]}

- ) - } - } + ); + }, + }, }, { name: "date_created", label: "Date Created", options: { filter: true, - setCellHeaderProps: () => { return { width:"20%"}}, - setCellProps: () => { return { width:"20%"} }, + setCellHeaderProps: () => { + return { width: "20%" }; + }, + setCellProps: () => { + return { width: "20%" }; + }, customBodyRender: (date) => { var year = ""; var month = ""; var day = ""; - for(var dateIndex = 0; dateIndex < date.length; dateIndex++) { - if(date[dateIndex]!=='-') { - if(dateIndex >= 0 && dateIndex < 4) { - year += date[dateIndex]; - } + for (var dateIndex = 0; dateIndex < date.length; dateIndex++) { + if (date[dateIndex] !== "-") { + if (dateIndex >= 0 && dateIndex < 4) { + year += date[dateIndex]; + } - if(dateIndex === 5 || dateIndex === 6) { - month += date[dateIndex]; - } + if (dateIndex === 5 || dateIndex === 6) { + month += date[dateIndex]; + } - if(dateIndex > 6 && dateIndex < date.length) { - day += date[dateIndex]; - } + if (dateIndex > 6 && dateIndex < date.length) { + day += date[dateIndex]; } + } } - return( -

{month+'/'+day+'/'+year}

- ) - } - } + return

{month + "/" + day + "/" + year}

; + }, + }, }, { name: "team_id", @@ -83,22 +116,73 @@ class ViewTeams extends Component{ options: { filter: false, sort: false, - setCellHeaderProps: () => { return { align:"center", width:"10%", className:"button-column-alignment"}}, - setCellProps: () => { return { align:"center", width:"10%", className:"button-column-alignment"} }, + setCellHeaderProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, + setCellProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, + customBodyRender: (teamId) => { + return ( + { + setAddTeamTabWithTeam(teams, teamId, users, "AddTeam"); + }} + aria-label="editTeamIconButton" + > + + + ); + }, + }, + }, + { + name: "team_id", + label: "Delete", + options: { + filter: false, + sort: false, + setCellHeaderProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, + setCellProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, customBodyRender: (teamId) => { - return( + return ( { - setAddTeamTabWithTeam(teams, teamId, users, "AddTeam");; - }} - aria-label="editTeamIconButton" + align="center" + onClick={() => { + if ( + window.confirm("Are you sure you want to delete this team?") + ) { + this.deleteTeam(teamId); + } + }} + aria-label="deleteTeamIconButton" > - + - ) - } - } + ); + }, + }, }, { name: "team_id", @@ -106,10 +190,22 @@ class ViewTeams extends Component{ options: { filter: false, sort: false, - setCellHeaderProps: () => { return { align:"center", width:"10%", className:"button-column-alignment"}}, - setCellProps: () => { return { align:"center", width:"10%", className:"button-column-alignment"} }, + setCellHeaderProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, + setCellProps: () => { + return { + align: "center", + width: "10%", + className: "button-column-alignment", + }; + }, customBodyRender: (teamId) => { - return( + return ( { @@ -117,11 +213,11 @@ class ViewTeams extends Component{ }} aria-label="viewTeamsIconButton" > - - - ) - } - } + + + ); + }, + }, }, ]; @@ -133,16 +229,17 @@ class ViewTeams extends Component{ selectableRows: "none", selectableRowsHeader: false, responsive: "vertical", - tableBodyMaxHeight: "55vh" + tableBodyMaxHeight: "55vh", }; return ( - - ) + + ); } } -export default ViewTeams; \ No newline at end of file +export default ViewTeams; diff --git a/FrontEndReact/src/View/Admin/View/ViewUsers/AdminViewUsers.js b/FrontEndReact/src/View/Admin/View/ViewUsers/AdminViewUsers.js index 48f21a610..74d4d9e73 100644 --- a/FrontEndReact/src/View/Admin/View/ViewUsers/AdminViewUsers.js +++ b/FrontEndReact/src/View/Admin/View/ViewUsers/AdminViewUsers.js @@ -15,7 +15,6 @@ class AdminViewUsers extends Component { super(props); this.state = { - successMessage: this.props.navbar.state.successMessage, errorMessage: null, isLoaded: false, users: null, @@ -40,17 +39,6 @@ class AdminViewUsers extends Component { "/role?", "roles", this); } - componentDidUpdate() { - if (this.state.successMessage !== null) { - setTimeout(() => { - this.setState({ - successMessage: null - }); - this.props.navbar.confirmCreateResource("User"); - }, 3000); - } - } - render() { const { errorMessage, @@ -87,11 +75,10 @@ class AdminViewUsers extends Component { return( - {this.state.successMessage !== null && + {state.successMessage !== null &&
diff --git a/FrontEndReact/src/View/Components/BackButtonResource.js b/FrontEndReact/src/View/Components/BackButtonResource.js index e6f8d9f52..9571f203b 100644 --- a/FrontEndReact/src/View/Components/BackButtonResource.js +++ b/FrontEndReact/src/View/Components/BackButtonResource.js @@ -25,7 +25,7 @@ export default function BackButtonResource (props){ { - confirmResource(props.tabSelected); + confirmResource(props.tabSelected, 0); }} variant="contained" startIcon={} diff --git a/FrontEndReact/src/View/Components/DeleteConfirmation.js b/FrontEndReact/src/View/Components/DeleteConfirmation.js new file mode 100644 index 000000000..68842b0cd --- /dev/null +++ b/FrontEndReact/src/View/Components/DeleteConfirmation.js @@ -0,0 +1,42 @@ +import React from "react"; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; + +export default function DeleteConfirmation( props ) { + var userInformation = `${props.userFirstName} ${props.userLastName}`; + + return ( + + + + {"Confirm Deleting User"} + + + + + Warning! This action can not be undone.

+ Deleting will permanently remove {userInformation} from the entire database. +
+
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/FrontEndReact/src/View/Components/DropConfirmation.js b/FrontEndReact/src/View/Components/DropConfirmation.js index 45804b677..a03f7d0e8 100644 --- a/FrontEndReact/src/View/Components/DropConfirmation.js +++ b/FrontEndReact/src/View/Components/DropConfirmation.js @@ -6,7 +6,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; -export default function ResponsiveDialog( props ) { +export default function DropConfirmation( props ) { var userInformation = `${props.userFirstName} ${props.userLastName}`; return ( @@ -16,7 +16,7 @@ export default function ResponsiveDialog( props ) { open={props.show} aria-labelledby="responsive-dialog-title" > - + {"Confirm Dropping User"} diff --git a/FrontEndReact/src/View/Login/Login.js b/FrontEndReact/src/View/Login/Login.js index c0879fbb1..8256e7eaa 100644 --- a/FrontEndReact/src/View/Login/Login.js +++ b/FrontEndReact/src/View/Login/Login.js @@ -55,11 +55,12 @@ class Login extends Component { }; this.login = () => { - const { + var { email, password, } = this.state; + email = email.toLowerCase(); if (email.trim() === '' || password.trim() === '') { this.setState({ errors: { @@ -70,9 +71,16 @@ class Login extends Component { } else { fetch( - apiUrl + `/login?email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`, + apiUrl + "/login", { - method: "POST" + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: email, + password: password, + }), } ) .then(res => res.json()) @@ -138,7 +146,6 @@ class Login extends Component { (result) => { if(result["success"]) { - console.log("results: ",result); cookies.set('access_token', result['headers']['access_token'], {'sameSite': 'strict'}); this.setState({ diff --git a/FrontEndReact/src/View/Login/SetNewPassword.js b/FrontEndReact/src/View/Login/SetNewPassword.js index 9d09f527b..3a2afb456 100644 --- a/FrontEndReact/src/View/Login/SetNewPassword.js +++ b/FrontEndReact/src/View/Login/SetNewPassword.js @@ -156,9 +156,17 @@ class SetNewPassword extends Component { } fetch( - apiUrl + `/password?email=${this.props.email}&password=${pass1}`, - - { method: 'PUT' } + apiUrl + "/password", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: this.props.email, + password: pass1, + }), + } ) .then(res => res.json()) diff --git a/FrontEndReact/src/View/Logout/Logout.js b/FrontEndReact/src/View/Logout/Logout.js index 42bcf0d2c..473d60085 100644 --- a/FrontEndReact/src/View/Logout/Logout.js +++ b/FrontEndReact/src/View/Logout/Logout.js @@ -21,9 +21,16 @@ class Logout extends Component { const userId = cookies.get('user')['user_id']; fetch( - apiUrl + `/logout?user_id=${userId}&access_token=${accessToken}&refresh_token=${refreshToken}`, + apiUrl + `/logout?user_id=${userId}`, { - method:'POST' + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + }), } ) .then(res => res.json()) diff --git a/FrontEndReact/src/View/Navbar/AppState.js b/FrontEndReact/src/View/Navbar/AppState.js index 777887c2b..87fc14b0c 100644 --- a/FrontEndReact/src/View/Navbar/AppState.js +++ b/FrontEndReact/src/View/Navbar/AppState.js @@ -51,6 +51,7 @@ class AppState extends Component { chosenAssessmentTask: null, chosenCompleteAssessmentTask: null, unitOfAssessment: null, + chosenCompleteAssessmentTaskIsReadOnly: false, team: null, addTeam: true, @@ -65,6 +66,11 @@ class AppState extends Component { userConsent: null, addTeamAction: null, + + successMessage: null, + successMessageTimeout: undefined, + + addCustomRubric: null, } this.setNewTab = (newTab) => { @@ -123,7 +129,9 @@ class AppState extends Component { } } - this.setAssessmentTaskInstructions = (assessmentTasks, assessmentTaskId, completedAssessments=null) => { // wip + this.setAssessmentTaskInstructions = (assessmentTasks, assessmentTaskId, completedAssessments=null, { + readOnly = false + }={}) => { // wip var completedAssessment = null; if (completedAssessments) { @@ -135,7 +143,8 @@ class AppState extends Component { activeTab: "AssessmentTaskInstructions", chosenCompleteAssessmentTask: completedAssessments ? completedAssessment : null, chosenAssessmentTask: assessmentTask, - unitOfAssessment: assessmentTask["unit_of_assessment"] + unitOfAssessment: assessmentTask["unit_of_assessment"], + chosenCompleteAssessmentTaskIsReadOnly: readOnly, }); } @@ -208,7 +217,6 @@ class AppState extends Component { }); } } - this.setAddTeamTabWithTeam = (teams, teamId, users, tab, addTeamAction) => { var newTeam = null; @@ -246,7 +254,8 @@ class AppState extends Component { activeTab: "CompleteAssessment", chosenAssessmentTask: null, unitOfAssessment: null, - chosenCompleteAssessmentTask: null + chosenCompleteAssessmentTask: null, + chosenCompleteAssessmentTaskIsReadOnly: false, }); } else { @@ -260,6 +269,7 @@ class AppState extends Component { this.setState({ activeTab: "CompleteAssessment", chosenCompleteAssessmentTask: newCompletedAssessmentTask, + chosenCompleteAssessmentTaskIsReadOnly: false, chosenAssessmentTask: chosenAssessmentTask, unitOfAssessment: chosenAssessmentTask["unit_of_assessment"] }); @@ -312,16 +322,27 @@ class AppState extends Component { }); } - this.confirmCreateResource = (resource) => { + this.setAddCustomRubric = (addCustomRubric) => { + + this.setState({ + activeTab: "AddCustomRubric", + addCustomRubric: addCustomRubric + }); + } + + this.confirmCreateResource = (resource, delay = 1000) => { setTimeout(() => { if (document.getElementsByClassName("alert-danger")[0] === undefined) { if (resource === "User" || resource === "UserBulkUpload") { this.setState({ - successMessage: resource === "UserBulkUpload" ? "The user bulk upload was successful!" : null, activeTab: this.props.isSuperAdmin ? "SuperAdminUsers" : "Users", user: null, addUser: null }); + + if (resource === "UserBulkUpload") { + this.setSuccessMessage("The user bulk upload was successful!"); + } } else if (resource === "Course") { this.setState({ @@ -344,11 +365,14 @@ class AppState extends Component { } else if (resource === "Team" || resource === "TeamBulkUpload") { this.setState({ - successMessage: resource === "TeamBulkUpload" ? "The team bulk upload was successful!" : null, activeTab: "Teams", team: null, addTeam: true }); + + if (resource === "TeamBulkUpload") { + this.setSuccessMessage("The team bulk upload was successful!"); + } } else if (resource==="TeamMembers") { this.setState({ @@ -378,7 +402,7 @@ class AppState extends Component { }); } } - }, 1000); + }, delay); } this.Reset = (listOfElements) => { @@ -390,6 +414,22 @@ class AppState extends Component { } } } + + this.setSuccessMessage = (newSuccessMessage) => { + clearTimeout(this.state.successMessageTimeout); + + const timeoutId = setTimeout(() => { + this.setState({ + successMessage: null, + successMessageTimeout: undefined, + }); + }, 3000); + + this.setState({ + successMessage: newSuccessMessage, + successMessageTimeout: timeoutId, + }); + }; } // The commented out code below saves the state of the Navbar, diff --git a/FrontEndReact/src/View/Navbar/UserAccount.js b/FrontEndReact/src/View/Navbar/UserAccount.js index e7fd028af..826ed4bcf 100644 --- a/FrontEndReact/src/View/Navbar/UserAccount.js +++ b/FrontEndReact/src/View/Navbar/UserAccount.js @@ -174,8 +174,17 @@ class UserAccount extends Component { } fetch( - apiUrl + `/password?email=${user.email}&password=${pass1}`, - { method: 'PUT' } + apiUrl + "/password", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: user.email, + password: pass1, + }), + } ) .then(res => res.json()) .then( @@ -204,7 +213,6 @@ class UserAccount extends Component { componentDidMount() { const cookies = new Cookies(); const user = cookies.get('user'); - console.log(user) if (user) { this.setState({ diff --git a/FrontEndReact/src/View/Student/View/AssessmentTask/StudentViewAssessmentTask.js b/FrontEndReact/src/View/Student/View/AssessmentTask/StudentViewAssessmentTask.js index cefea117f..a51a9b376 100644 --- a/FrontEndReact/src/View/Student/View/AssessmentTask/StudentViewAssessmentTask.js +++ b/FrontEndReact/src/View/Student/View/AssessmentTask/StudentViewAssessmentTask.js @@ -34,19 +34,19 @@ class StudentViewAssessmentTask extends Component { genericResourceGET( `/assessment_task?course_id=${chosenCourseID}`, - "assessmentTasks", this); + "assessment_tasks", this, {dest: "assessmentTasks"}); genericResourceGET( `/completed_assessment?course_id=${chosenCourseID}`, - "completedAssessments", this); + "completed_assessments", this, {dest: "completedAssessments"}); } else { // If the user is a TA, this returns assessments completed by the TA genericResourceGET( `/assessment_task?course_id=${chosenCourseID}&role_id=${userRole}`, - "assessmentTasks", this); + "assessment_tasks", this, {dest: "assessmentTasks"}); genericResourceGET( `/completed_assessment?course_id=${chosenCourseID}&role_id=${userRole}`, - "completedAssessments", this); + "completed_assessments", this, {dest: "completedAssessments"}); } genericResourceGET( @@ -58,7 +58,7 @@ class StudentViewAssessmentTask extends Component { genericResourceGET( `/course?course_id=${chosenCourseID}`, - "counts", this); + "course_count", this, {dest: "counts"}); } render() { @@ -86,7 +86,7 @@ class StudentViewAssessmentTask extends Component {
) - } else if (!isLoaded ||!assessmentTasks || !checkin || !rubrics || !counts) { + } else if (!isLoaded || !assessmentTasks || !checkin || !rubrics || !counts || !completedAssessments) { return( ) diff --git a/FrontEndReact/src/View/Student/View/AssessmentTask/ViewAssessmentTaskInstructions.js b/FrontEndReact/src/View/Student/View/AssessmentTask/ViewAssessmentTaskInstructions.js index ec9412d37..92c622280 100644 --- a/FrontEndReact/src/View/Student/View/AssessmentTask/ViewAssessmentTaskInstructions.js +++ b/FrontEndReact/src/View/Student/View/AssessmentTask/ViewAssessmentTaskInstructions.js @@ -1,6 +1,8 @@ import React, { Component } from "react"; import 'bootstrap/dist/css/bootstrap.css'; import Button from '@mui/material/Button'; +import {genericResourcePOST} from '../../../../utility.js'; +import Cookies from 'universal-cookie'; @@ -14,9 +16,41 @@ class ViewAssessmentTaskInstructions extends Component { } } - handleContinueClick = () => { + handleContinueClick = async () => { + const navbar = this.props.navbar; + const state = navbar.state; + const cookies = new Cookies(); + + try { + const userId = cookies.get('user')?.user_id; + if (!userId) { + console.error('User ID not found in cookies'); + this.props.navbar.setNewTab("ViewStudentCompleteAssessmentTask"); + return; + } + + const completedAssessmentId = state.chosenCompleteAssessmentTask?.completed_assessment_id; + if (!completedAssessmentId) { + console.error('Completed assessment ID not found'); + this.props.navbar.setNewTab("ViewStudentCompleteAssessmentTask"); + return; + } + + await genericResourcePOST( + '/feedback', + this, + JSON.stringify({ + user_id: userId, + completed_assessment_id: completedAssessmentId + }) + ); + + } catch (error) { + console.error('Error recording feedback view:', error); + } + this.props.navbar.setNewTab("ViewStudentCompleteAssessmentTask"); - } +} render() { var assessmentTaskName = this.props.navbar.state.chosenAssessmentTask.assessmentTaskName; diff --git a/FrontEndReact/src/View/Student/View/BuildTeam/BuildTeam.js b/FrontEndReact/src/View/Student/View/BuildTeam/BuildTeam.js index 0ec647ea7..9106b3bee 100644 --- a/FrontEndReact/src/View/Student/View/BuildTeam/BuildTeam.js +++ b/FrontEndReact/src/View/Student/View/BuildTeam/BuildTeam.js @@ -19,10 +19,11 @@ class BuildTeamTable extends Component { }; } - handleConfirmTeamClick = () => { + // TO DO + // handleConfirmTeamClick = () => { // Add your confirm team functionality here - console.log('Confirm Team Button Clicked'); - }; + // console.log('Confirm Team Button Clicked'); + // }; handleChange = (userId) => (event) => { const { selected, unselected } = this.state; @@ -197,12 +198,12 @@ class BuildTeamTable extends Component { backgroundColor: "#2196F3", marginTop: "30px" }} - - onClick={ - () => { - console.log("Confirm Team"); - } - } + // TO DO + // onClick={ + // () => { + // console.log("Confirm Team"); + // } + // } > Confirm Team diff --git a/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/StudentCompletedAssessmentTasks.js b/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/StudentCompletedAssessmentTasks.js index 176ec5c04..e9bed4e28 100644 --- a/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/StudentCompletedAssessmentTasks.js +++ b/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/StudentCompletedAssessmentTasks.js @@ -31,18 +31,18 @@ class StudentCompletedAssessmentTasks extends Component { genericResourceGET( `/assessment_task?course_id=${chosenCourseID}`, - "assessmentTasks", this + "assessment_tasks", this, {dest: "assessmentTasks"} ); if (userRole === 5) { // If the user is a student, this returns completed assessments for the student genericResourceGET( `/completed_assessment?course_id=${chosenCourseID}`, - "completedAssessments", this + "completed_assessments", this, {dest: "completedAssessments"} ); } else { // If the user is a TA, this returns assessments completed by the TA genericResourceGET( `/completed_assessment?course_id=${chosenCourseID}&role_id=${userRole}`, - "completedAssessments", this); + "completed_assessments", this, {dest: "completedAssessments"}); } } diff --git a/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/ViewCompletedAssessmentTasks.js b/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/ViewCompletedAssessmentTasks.js index f3e491995..9728b156f 100644 --- a/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/ViewCompletedAssessmentTasks.js +++ b/FrontEndReact/src/View/Student/View/CompletedAssessmentTask/ViewCompletedAssessmentTasks.js @@ -94,7 +94,7 @@ class ViewCompletedAssessmentTasks extends Component {
{ - navbar.setAssessmentTaskInstructions(assessmentTasks, atId, completedAssessments); + navbar.setAssessmentTaskInstructions(assessmentTasks, atId, completedAssessments, { readOnly: true }); }} aria-label="completedAssessmentTasksViewIconButton" > diff --git a/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/ConfirmCurrentTeam.js b/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/ConfirmCurrentTeam.js index 52c69e746..4510bf30f 100644 --- a/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/ConfirmCurrentTeam.js +++ b/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/ConfirmCurrentTeam.js @@ -16,9 +16,11 @@ class ConfirmCurrentTeamTable extends Component { var navbar = this.props.navbar; var atId = navbar.state.chosenAssessmentTask["assessment_task_id"]; - genericResourcePOST(`/checkin?assessment_task_id=${atId}&team_id=${this.props.teamId}`, this, {}); - - navbar.setNewTab("StudentDashboard"); + genericResourcePOST(`/checkin?assessment_task_id=${atId}&team_id=${this.props.teamId}`, this, {}).then((result) => { + if (result !== undefined && result.errorMessage === null) { + navbar.setNewTab("StudentDashboard"); + } + }); }; render() { diff --git a/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/StudentConfirmCurrentTeam.js b/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/StudentConfirmCurrentTeam.js index aa98a95b5..2e00e7e89 100644 --- a/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/StudentConfirmCurrentTeam.js +++ b/FrontEndReact/src/View/Student/View/ConfirmCurrentTeam/StudentConfirmCurrentTeam.js @@ -23,7 +23,7 @@ class StudentConfirmCurrentTeam extends Component { genericResourceGET( `/team_members?course_id=${courseId}`, - "teamMembers", this + "team_members", this, {dest: "teamMembers"} ); } diff --git a/FrontEndReact/src/View/Student/View/SelectTeam/SelectTeam.js b/FrontEndReact/src/View/Student/View/SelectTeam/SelectTeam.js index 3f73d901c..975a76e3e 100644 --- a/FrontEndReact/src/View/Student/View/SelectTeam/SelectTeam.js +++ b/FrontEndReact/src/View/Student/View/SelectTeam/SelectTeam.js @@ -34,38 +34,34 @@ class SelectTeam extends Component { }); return; } - - genericResourcePOST(`/checkin?assessment_task_id=${atId}&team_id=${this.state.teamID}`, this, {}); - - navbar.setNewTab("StudentDashboard"); - } - }; + + genericResourcePOST(`/checkin?assessment_task_id=${atId}&team_id=${this.state.teamID}`, this, {}).then((result) => { + if (result !== undefined && result.errorMessage === null) { + navbar.setNewTab("StudentDashboard"); + } + }); + }; +}; componentDidMount() { let course = this.props.navbar.state.chosenCourse; if (course["use_fixed_teams"]) { - let courseID = this.props.navbar.state.chosenCourse["course_id"]; - + + let courseID = course["course_id"]; genericResourceGET( `/team?course_id=${courseID}`, "teams", this); } else { - // using Ad Hoc teams - let teams = []; - let numTeams = this.props.navbar.state.chosenAssessmentTask["number_of_teams"]; - - for(let i = 1; i <= numTeams; i++) { - teams.push({ - "team_id": i, - "team_name": `Team ${i}` - }); - } + // using Ad Hoc teams + let navbar = this.props.navbar; + let atId = navbar.state.chosenAssessmentTask["assessment_task_id"]; - this.setState({ - teams: teams - }); + genericResourceGET( + `/team/nonfull-adhoc?assessment_task_id=${atId}`, + "teams", this + ) } } diff --git a/FrontEndReact/src/View/Student/View/Team/StudentTeamMembers.js b/FrontEndReact/src/View/Student/View/Team/StudentTeamMembers.js index 1d4d7e1cb..09a70d3f7 100644 --- a/FrontEndReact/src/View/Student/View/Team/StudentTeamMembers.js +++ b/FrontEndReact/src/View/Student/View/Team/StudentTeamMembers.js @@ -69,9 +69,10 @@ class StudentTeamMembers extends Component { diff --git a/FrontEndReact/src/View/Student/View/TeamPassword/CodeRequirement.js b/FrontEndReact/src/View/Student/View/TeamPassword/CodeRequirement.js index 76bd0ce35..565dd309d 100644 --- a/FrontEndReact/src/View/Student/View/TeamPassword/CodeRequirement.js +++ b/FrontEndReact/src/View/Student/View/TeamPassword/CodeRequirement.js @@ -4,6 +4,7 @@ import { Box, TextField } from '@mui/material'; import CustomButton from '../Components/CustomButton.js'; import ErrorMessage from '../../../Error/ErrorMessage.js'; import { genericResourceGET } from '../../../../utility.js'; +import Loading from '../../../Loading/Loading.js'; @@ -50,20 +51,29 @@ class CodeRequirement extends Component { genericResourceGET( `/assessment_task?assessment_task_id=${atId}`, - "assessmentTasks", this); + "assessment_tasks", this, {dest: "assessmentTasks"}); } render() { const { errorMessage } = this.state; - return ( - + if (errorMessage) { + return ( {errorMessage && } - + ); + + } else if (this.state.assessmentTasks === null) { + return ( + + ); + } + + return ( +
-1 ? apiUrl + fetchURL + `&user_id=${cookies.get('user')['user_id']}` : apiUrl + fetchURL + `?user_id=${cookies.get('user')['user_id']}`; +} + +async function genericResourceFetch(fetchURL, resource, component, type, body, options = {}) { + const { + dest = resource + } = options; + const cookies = new Cookies(); if(cookies.get('access_token') && cookies.get('refresh_token') && cookies.get('user')) { - let url = fetchURL.indexOf('?') > -1 ? apiUrl + fetchURL + `&user_id=${cookies.get('user')['user_id']}` : apiUrl + fetchURL + `?user_id=${cookies.get('user')['user_id']}`; + let url = createApiRequestUrl(fetchURL, cookies); var headers = { "Authorization": "Bearer " + cookies.get('access_token') @@ -49,7 +62,7 @@ async function genericResourceFetch(fetchURL, resource, component, type, body) { } const result = await response.json(); - + if(result['success']) { let state = {}; @@ -57,24 +70,8 @@ async function genericResourceFetch(fetchURL, resource, component, type, body) { state['errorMessage'] = null; - if(resource != null) { - var getResource = resource; - - getResource = (getResource === "assessmentTasks") ? "assessment_tasks": getResource; - - getResource = (getResource === "completedAssessments") ? "completed_assessments": getResource; - - getResource = (getResource === "csvCreation") ? "csv_creation": getResource; - - getResource = (getResource === "teamMembers") ? "team_members": getResource; - - getResource = (getResource === "indiv_users") ? "users": getResource; - - getResource = (getResource === "counts") ? "course_count": getResource; - - getResource = (getResource === "team") ? "teams": getResource; - - state[resource] = result['content'][getResource][0]; + if(resource) { + state[dest] = result['content'][resource][0]; } component.setState(state); @@ -110,6 +107,24 @@ async function genericResourceFetch(fetchURL, resource, component, type, body) { } } +export function createEventSource(fetchURL, onMessage) { + const cookies = new Cookies(); + + if (cookies.get('access_token') && cookies.get('refresh_token') && cookies.get('user')) { + const url = createApiRequestUrl(fetchURL, cookies); + + const headers = { + "Authorization": "Bearer " + cookies.get('access_token') + }; + + return eventsource.createEventSource({ + url, + headers, + onMessage, + }); + } +} + export function parseRoleNames(roles) { var allRoles = {}; @@ -215,11 +230,15 @@ export function validPasword(password) { // NOTE: This function is used to format the Date so that it doesn't have any timezone issues export function formatDueDate(dueDate, timeZone) { const timeZoneMap = { - "EST": "America/New_York", - "CST": "America/Chicago", - "MST": "America/Denver", "PST": "America/Los_Angeles", - "UTC": "" + "PDT": "America/Los_Angeles", + "MST": "America/Denver", + "MDT": "America/Denver", + "CST": "America/Chicago", + "CDT": "America/Chicago", + "EST": "America/New_York", + "EDT": "America/New_York", + "UTC": "UTC" }; const timeZoneId = timeZoneMap[timeZone]; diff --git a/compose.yml b/compose.yml index ff51ed448..548cf4c07 100644 --- a/compose.yml +++ b/compose.yml @@ -28,8 +28,8 @@ services: redis: image: redis:7.2.4 - ports: - - "127.0.0.1:6379:6379" + ports: [] # Disable exposed port so that Redis can run inside of the container and not be exposed to host machine. + #- "127.0.0.1:6379:6379" # Remove [] next to ports and uncomemnt this line to expose redis to the system again networks: - app-network