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(
+