Coverage for app/logic/serviceLearningCourses.py: 84%

173 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-03-20 17:02 +0000

1from flask import g 

2from peewee import fn, JOIN, ModelSelect 

3import re as regex 

4from openpyxl import load_workbook 

5from collections import defaultdict 

6from typing import DefaultDict, List, Dict, Any, Union 

7 

8from app.models import mainDB 

9from app.models import DoesNotExist 

10from app.models.course import Course 

11from app.models.user import User 

12from app.models.term import Term 

13from app.models.courseInstructor import CourseInstructor 

14from app.models.courseParticipant import CourseParticipant 

15from app.models.courseStatus import CourseStatus 

16from app.models.courseQuestion import CourseQuestion 

17from app.models.attachmentUpload import AttachmentUpload 

18from app.models.questionNote import QuestionNote 

19from app.models.note import Note 

20from app.logic.createLogs import createAdminLog 

21from app.logic.fileHandler import FileHandler 

22from app.logic.term import addPastTerm 

23 

24def getSLProposalInfoForUser(user: User) -> Dict[int, Dict[str, Any]]: 

25 """ 

26 Given a user, returns a nested dictionary containing each course 

27 associated with the user and its course data. Used to populate  

28 Service-Learning proposal table. 

29 """ 

30 courses: List[Course] = list(Course.select(Course, Term, User, CourseStatus) 

31 .join(CourseInstructor).switch() 

32 .join(Term).switch() 

33 .join(CourseStatus).switch() 

34 .join(User) 

35 .where((CourseInstructor.user==user) | (Course.createdBy==user)) 

36 .order_by(Course.term.desc(), Course.status)) 

37 

38 courseDict: Dict[int, Dict[str, Any]] = {} 

39 for course in courses: 

40 courseInstructors: List[CourseInstructor] = list(CourseInstructor.select(CourseInstructor, User).join(User).where(CourseInstructor.course==course)) 

41 faculty: List[str] = [f"{instructor.user.firstName} {instructor.user.lastName}" for instructor in courseInstructors] 

42 

43 

44 courseDict[course.id] = {"id":course.id, 

45 "creator":f"{course.createdBy.firstName} {course.createdBy.lastName}", 

46 "name":course.courseName, 

47 "faculty": faculty, 

48 "term": course.term, 

49 "status": course.status.status} 

50 return courseDict 

51 

52def saveCourseParticipantsToDatabase(cpPreview: Dict[str, Dict[str, Dict[str, List[Dict[str, Any]]]]]) -> None: 

53 for term, terminfo in cpPreview.items(): 

54 termObj: Term = Term.get_or_none(description = term) or addPastTerm(term) 

55 if not termObj: 

56 print(f"Unable to find or create term {term}") 

57 continue 

58 

59 for course, courseInfo in terminfo['courses'].items(): 

60 if 'errorMsg' in courseInfo and courseInfo['errorMsg']: 

61 print(f"Unable to save course {course}. {courseInfo['errorMsg']}") 

62 continue 

63 

64 courseObj: Course = Course.get_or_create( 

65 courseAbbreviation = course, 

66 term = termObj, 

67 defaults = {"CourseName" : "", 

68 "sectionDesignation" : "", 

69 "courseCredit" : "1", 

70 "term" : termObj, 

71 "status" : 4, 

72 "createdBy" : g.current_user, 

73 "serviceLearningDesignatedSections" : "", 

74 "previouslyApprovedDescription" : "" })[0] 

75 

76 for userDict in courseInfo['students']: 

77 if userDict['errorMsg']: 

78 print(f"Unable to save student. {userDict['errorMsg']}") 

79 continue 

80 

81 CourseParticipant.get_or_create(user=userDict['user'], 

82 course=courseObj, 

83 hoursEarned=20) 

84 

85def unapprovedCourses(termId: int) -> List[Course]: 

86 """ 

87 Queries the database to get all the neccessary information for 

88 submitted/unapproved courses. 

89 """ 

90 

91 unapprovedCourses: List[Course] = list(Course.select(Course, Term, CourseStatus, fn.GROUP_CONCAT(" " ,User.firstName, " ", User.lastName).alias('instructors')) 

92 .join(CourseInstructor, JOIN.LEFT_OUTER) 

93 .join(User, JOIN.LEFT_OUTER).switch(Course) 

94 .join(CourseStatus).switch(Course) 

95 .join(Term) 

96 .where(Term.id == termId, 

97 Course.status.in_([CourseStatus.SUBMITTED, CourseStatus.IN_PROGRESS])) 

98 .group_by(Course, Term, CourseStatus) 

99 .order_by(Course.status)) 

100 

101 return unapprovedCourses 

102 

103def approvedCourses(termId: int) -> List[Course]: 

104 """ 

105 Queries the database to get all the necessary information for 

106 approved courses. 

107 """ 

108 approvedCourses: List[Course] = list(Course.select(Course, Term, CourseStatus, fn.GROUP_CONCAT(" " ,User.firstName, " ", User.lastName).alias('instructors')) 

109 .join(CourseInstructor, JOIN.LEFT_OUTER) 

110 .join(User, JOIN.LEFT_OUTER).switch(Course) 

111 .join(CourseStatus).switch(Course) 

112 .join(Term) 

113 .where(Term.id == termId, Course.status == CourseStatus.APPROVED) 

114 .group_by(Course, Term, CourseStatus)) 

115 

116 return approvedCourses 

117 

118def getInstructorCourses() -> Dict[User, str]: 

119 """ 

120 This function queries all of the course instructors and their classes and maps 

121 each instructor to its respective courses. 

122 """ 

123 instructors: List[CourseInstructor] = list(CourseInstructor.select(CourseInstructor, User, Course) 

124 .join(User).switch() 

125 .join(Course)) 

126 instructorToCoursesMap: DefaultDict[User, str] = defaultdict(list) 

127 

128 for instructor in instructors: 

129 if instructor.course.courseName not in instructorToCoursesMap[instructor.user]: 

130 instructorToCoursesMap[instructor.user].append(instructor.course.courseName) 

131 

132 return dict(instructorToCoursesMap) 

133 

134########### Course Actions ########### 

135 

136def renewProposal(courseID, term) -> Course: 

137 """ 

138 Renews proposal of ID passed in for the selected term. 

139 Sets status to in progress. 

140 """ 

141 oldCourse: Course = Course.get_by_id(courseID) 

142 newCourse: Course = Course.get_by_id(courseID) 

143 newCourse.id = None 

144 newCourse.term = Term.get_by_id(term) 

145 newCourse.status = CourseStatus.IN_PROGRESS 

146 newCourse.isPreviouslyApproved = True 

147 newCourse.save() 

148 questions: List[CourseQuestion] = list(CourseQuestion.select().where(CourseQuestion.course==oldCourse)) 

149 for question in questions: 

150 CourseQuestion.create(course=newCourse.id, 

151 questionContent=question.questionContent, 

152 questionNumber=question.questionNumber) 

153 

154 instructors = CourseInstructor.select().where(CourseInstructor.course==oldCourse.id) 

155 for instructor in instructors: 

156 CourseInstructor.create(course=newCourse.id, 

157 user=instructor.user) 

158 

159 return newCourse 

160 

161def withdrawProposal(courseID) -> None: 

162 """ 

163 Withdraws proposal of ID passed in. Removes foreign keys first. 

164 Key Dependencies: QuestionNote, CourseQuestion, CourseParticipant, 

165 CourseInstructor, Note 

166 """ 

167 

168 # delete syllabus 

169 try: 

170 syllabi: List[AttachmentUpload] = list(AttachmentUpload.select().where(AttachmentUpload.course==courseID)) 

171 for syllabus in syllabi: 

172 FileHandler(courseId = courseID).deleteFile(syllabus.id) 

173 

174 except DoesNotExist: 

175 print(f"File, {AttachmentUpload.fileName}, does not exist.") 

176 

177 # delete course object 

178 course: Course = Course.get(Course.id == courseID) 

179 courseName: str = course.courseName 

180 questions: List[CourseQuestion] = CourseQuestion.select().where(CourseQuestion.course == course) 

181 notes: List[Note] = list(Note.select(Note.id) 

182 .join(QuestionNote) 

183 .where(QuestionNote.question.in_(questions)) 

184 .distinct()) 

185 course.delete_instance(recursive=True) 

186 for note in notes: 

187 note.delete_instance() 

188 

189 createAdminLog(f"Withdrew SLC proposal: {courseName}") 

190 

191def createCourse(creator: str="No user provided") -> Course: 

192 """ 

193 Creates and returns an empty, in progress course. 

194 """ 

195 course: Course = Course.create(status=CourseStatus.IN_PROGRESS, createdBy=creator) 

196 for number in range(1, 7): 

197 CourseQuestion.create(course=course, questionNumber=number) 

198 

199 return course 

200 

201def updateCourse(courseData, attachments=None) -> Union[Course, bool]: 

202 """ 

203 This function will take in courseData for the SLC proposal page and a dictionary 

204 of instuctors assigned to the course and update the information in the db. 

205 """ 

206 with mainDB.atomic() as transaction: 

207 try: 

208 course: Course = Course.get_by_id(courseData['courseID']) 

209 

210 for toggler in ["slSectionsToggle", "permanentDesignation"]: 

211 courseData.setdefault(toggler, "off") 

212 

213 (Course.update(courseName=courseData["courseName"], 

214 courseAbbreviation=courseData["courseAbbreviation"], 

215 sectionDesignation=courseData["sectionDesignation"], 

216 courseCredit=courseData["credit"], 

217 isRegularlyOccurring=int(courseData["isRegularlyOccurring"]), 

218 term=courseData['term'], 

219 status=CourseStatus.SUBMITTED, 

220 isPreviouslyApproved=int(courseData["isPreviouslyApproved"]), 

221 previouslyApprovedDescription = courseData["previouslyApprovedDescription"], 

222 isAllSectionsServiceLearning=("on" in courseData["slSectionsToggle"]), 

223 serviceLearningDesignatedSections=courseData["slDesignation"], 

224 isPermanentlyDesignated=("on" in courseData["permanentDesignation"]), 

225 hasSlcComponent = int(courseData["hasSlcComponent"])) 

226 .where(Course.id == course.id).execute()) 

227 

228 # update the existing entry with the new question responses 

229 for questionIndex in range(1, 7): 

230 (CourseQuestion.update(questionContent=courseData[f"{questionIndex}"]) 

231 .where((CourseQuestion.questionNumber == questionIndex) & 

232 (CourseQuestion.course==course)).execute()) 

233 

234 # delete all course instructors and create entries for the updated instructors  

235 CourseInstructor.delete().where(CourseInstructor.course == course).execute() 

236 instructorList: List[str] = courseData.getlist('instructor[]') 

237 for instructor in instructorList: 

238 CourseInstructor.create(course=course, user=instructor) 

239 

240 # save attachments to course if applicable 

241 if attachments: 

242 addFile: FileHandler = FileHandler(attachments, courseId=course.id) 

243 addFile.saveFiles() 

244 

245 createAdminLog(f"Saved SLC proposal: {courseData['courseName']}") 

246 

247 return Course.get_by_id(course.id) 

248 

249 except Exception as e: 

250 print(e) 

251 transaction.rollback() 

252 return False 

253 

254########### Course Actions ########### 

255 

256def parseUploadedFile(filePath): 

257 """ 

258 Parse an Excel document at the given `filePath` for courses and 

259 course participants. 

260 

261 The return value will be a tuple. The second value is a list of  

262 error message tuples. The first tuple value is the error message,  

263 and the second is a 0 or 1 indicating whether the error is a  

264 'general' error - 1 - or an error on a specific course, term, or  

265 person. The first value is a dictionary keyed by the term  

266 description. Each value is another dictionary, with a key for  

267 'displayMsg' and 'errorMsg', and a 'courses' key whose value is  

268 a dictionary with keys for the courses in the term. Each course  

269 has a 'displayMsg' and 'errorMsg' key, and a 'students' key  

270 that has a list of dictionaries with 'user', 'displayMsg', and  

271 'errorMsg' keys. 

272 E.g., 

273 { 

274 "Fall 2021": { 

275 "displayMsg": "", 

276 "errorMsg": "", 

277 "courses": { 

278 "CSC 330": { 

279 "displayMsg: "CSC 330 will be created", 

280 "errorMsg: "", 

281 "students": [ 

282 {'user':'ramsayb2',  

283 'displayMsg': 'Brian Ramsay', 

284 'errorMsg': ''}, 

285 {'user':'B0073235',  

286 'displayMsg': '', 

287 'errorMsg': 'ERROR: B0073235 does not exist!'}] 

288 }, 

289 "CSC 226": { 

290 "displayMsg": "CSC 226 matched to existing course 'Data Structures'.", 

291 "errorMsg": "", 

292 "students": [ 

293 {'user':'ramsayb2',  

294 'displayMsg': 'Brian Ramsay', 

295 'errorMsg': ''}, 

296 {'user':'lamichhanes',  

297 'displayMsg': 'Sandesh Lamichhane', 

298 'errorMsg': ''}] 

299 } 

300 } 

301 } 

302 } 

303 """ 

304 excelData = load_workbook(filename=filePath) 

305 excelSheet = excelData.active 

306 

307 result = {} 

308 errors = [] 

309 term = '' 

310 course = '' 

311 cellRow = 0 

312 

313 for row in excelSheet.iter_rows(): 

314 cellRow += 1 

315 cellVal = row[0].value 

316 if not cellVal: 

317 continue 

318 

319 # Look for a Term. Examples: Fall 2020 or Spring B 2021 

320 if regex.search(r"\b[a-zA-Z]{3,}( [AB])? \d{4}\b", str(cellVal)): 

321 errorMsg = '' 

322 if "Spring A" in cellVal or "Spring B" in cellVal: 

323 cellVal = "Spring " + cellVal.split()[-1] 

324 if "Fall A" in cellVal or "Fall B" in cellVal: 

325 cellVal = "Fall " + cellVal.split()[-1] 

326 

327 if cellVal.split()[0] not in ["Summer", "Spring", "Fall", "May"]: 

328 errorMsg = f"ERROR: '{cellVal}' is not a valid term in row {cellRow}." 

329 else: 

330 latestTerm = Term.select().order_by(Term.termOrder.desc()).get() 

331 isFutureTerm = latestTerm.termOrder < Term.convertDescriptionToTermOrder(cellVal) 

332 if isFutureTerm: 

333 errors.append(f"ERROR: '{cellVal}' is a future term in row {cellRow}.") 

334 else: 

335 validTerm = Term.get_or_none(Term.description == cellVal) 

336 

337 term = cellVal 

338 result[term] = { 

339 'displayMsg': term, 

340 'errorMsg': errorMsg, 

341 'courses': {} 

342 } 

343 if errorMsg: 

344 errors.append((errorMsg,0)) 

345 

346 # Look for a Course. Examples: FRN134 CSC 226 

347 elif regex.search(r"\b[A-Z]{2,4} ?\d{3}\b", str(cellVal)): 

348 errorMsg = displayMsg = '' 

349 if not term: 

350 displayMsg = cellVal 

351 errorMsg = "ERROR: No term was given for this course" 

352 else: 

353 existingCourse = Course.get_or_none(Course.courseAbbreviation == cellVal) 

354 displayMsg = f'{cellVal} will be created.' 

355 if existingCourse: 

356 displayMsg = f"{cellVal} matched to the existing course {existingCourse.courseName}." 

357 

358 course = cellVal 

359 result[term]['courses'][course] = { 

360 'displayMsg': displayMsg, 

361 'errorMsg': errorMsg, 

362 'students': [] 

363 } 

364 if errorMsg: 

365 errors.append((errorMsg,0)) 

366 

367 # Look for a B-Number. Example: B00123456 

368 elif regex.search(r"\b[B]\d{8}\b", str(cellVal)): 

369 errorMsg = displayMsg = '' 

370 if not course: 

371 errorMsg = "ERROR: No course is connected to this student" 

372 else: 

373 existingUser = User.get_or_none(User.bnumber == cellVal) 

374 if existingUser: 

375 displayMsg = f"{existingUser.firstName} {existingUser.lastName}" 

376 existingUser = existingUser.username 

377 else: 

378 errorMsg = f'ERROR: {row[1].value} with B# "{row[0].value}" does not exist.' 

379 

380 result[term]['courses'][course]['students'].append({ 

381 'user': (existingUser or cellVal), 

382 'displayMsg': displayMsg, 

383 'errorMsg': errorMsg}) 

384 if errorMsg: 

385 errors.append((errorMsg,0)) 

386 

387 elif cellVal: # but didn't match the regex 

388 errors.append((f'ERROR: "{cellVal}" in row {cellRow} of the Excel document does not appear to be a term, course, or valid B#.',1)) 

389 

390 

391 return result, errors