Coverage for app/logic/volunteerSpreadsheet.py: 100%

155 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2026-04-16 19:08 +0000

1from os import major 

2import xlsxwriter 

3from peewee import fn, Case, JOIN, SQL, Select 

4from collections import defaultdict 

5from datetime import date, datetime,time 

6from app import app 

7from app.models import mainDB 

8from app.models.celtsLabor import CeltsLabor 

9from app.models.eventParticipant import EventParticipant 

10from app.models.user import User 

11from app.models.program import Program 

12from app.models.event import Event 

13from app.models.term import Term 

14 

15### READ ME FIRST! ################################################################# 

16# 

17# It's very important that we understand the distinction between volunteers earning 

18# service hours and other things that we track in our system, like student labor,  

19# bonner students, trainings, etc. The way we use 'volunteer' may not necessarily 

20# be the way CELTS uses it. 

21# 

22#################################################################################### 

23 

24def getFallTerm(academicYear): 

25 return Term.get(Term.description % "Fall%", Term.academicYear == academicYear) 

26 

27def getSpringTerm(academicYear): 

28 return Term.get(Term.description % "Spring%", Term.academicYear == academicYear) 

29 

30 

31def getBaseQuery(academicYear): 

32 

33 # As we add joins to this query, watch out for duplicate participant rows being added 

34 

35 return (EventParticipant.select() 

36 .join(User).switch(EventParticipant) 

37 .join(Event) 

38 .join(Program).switch(Event) 

39 .join(Term) 

40 .where(Term.academicYear == academicYear, 

41 Event.deletionDate == None, 

42 Event.isCanceled == False) 

43 .order_by(Event.startDate)) 

44 

45 

46def getUniqueVolunteers(academicYear): 

47 base = getBaseQuery(academicYear) 

48 

49 columns = ["Full Name", "Email", "B-Number"] 

50 subquery = (base.select(fn.DISTINCT(EventParticipant.user_id).alias('user_id'), fn.CONCAT(User.firstName, ' ', User.lastName).alias("fullname"), User.bnumber) 

51 .where(Event.isService == True)).alias('subq') 

52 query = Select().from_(subquery).select(subquery.c.fullname, fn.CONCAT(subquery.c.user_id,'@berea.edu'), subquery.c.bnumber) 

53 

54 return (columns,query.tuples().execute(mainDB)) 

55 

56 

57def volunteerProgramHours(academicYear): 

58 base = getBaseQuery(academicYear) 

59 

60 columns = ["Program Name", "Volunteer Hours", "Volunteer Name", "Volunteer Email", "Volunteer B-Number"] 

61 query = (base.select(Program.programName, 

62 fn.SUM(EventParticipant.hoursEarned), 

63 fn.CONCAT(User.firstName, ' ', User.lastName), 

64 fn.CONCAT(EventParticipant.user_id,'@berea.edu'), 

65 User.bnumber) 

66 .where(Event.isService == True) 

67 .group_by(Program.programName, EventParticipant.user_id)) 

68 

69 return (columns, query.tuples()) 

70 

71def onlyCompletedAllVolunteer(academicYear): 

72 base = getBaseQuery(academicYear) 

73 base2 = getBaseQuery(academicYear) 

74 

75 columns = ["Full Name", "Email", "B-Number"] 

76 subQuery = base2.select(EventParticipant.user_id).where(~Event.isAllVolunteerTraining) 

77 

78 query = (base.select(fn.CONCAT(User.firstName, ' ', User.lastName), 

79 fn.CONCAT(EventParticipant.user_id,'@berea.edu'), 

80 User.bnumber) 

81 .where(Event.isAllVolunteerTraining, EventParticipant.user_id.not_in(subQuery))) 

82 

83 return (columns, query.tuples()) 

84 

85def totalHours(academicYear): 

86 base = getBaseQuery(academicYear) 

87 

88 columns = ["Total Service Hours", "Total Training Hours", "Other Participation Hours"] 

89 query = base.select(fn.SUM(Case(None,((Event.isService, EventParticipant.hoursEarned),),0)), 

90 fn.SUM(Case(None,((Event.isTraining, EventParticipant.hoursEarned),),0)), 

91 fn.SUM(Case(None,((~Event.isService & ~Event.isTraining, EventParticipant.hoursEarned),),0))) 

92 

93 return (columns, query.tuples()) 

94 

95def totalHoursByProgram(academicYear): 

96 base = getBaseQuery(academicYear) 

97 

98 columns = ["Program", "Service Hours", "Training Hours", "Other Hours"] 

99 query = (base.select(Program.programName, 

100 fn.SUM(Case(None,((Event.isService, EventParticipant.hoursEarned),),0)), 

101 fn.SUM(Case(None,((Event.isTraining, EventParticipant.hoursEarned),),0)), 

102 fn.SUM(Case(None,((~Event.isService & ~Event.isTraining, EventParticipant.hoursEarned),),0))) 

103 .group_by(Program.programName) 

104 .order_by(Program.programName)) 

105 

106 return (columns, query.tuples()) 

107 

108def makeCase(fieldname): 

109 return Case(fieldname,((1, "Yes"),(0, "No"),),"None") 

110 

111def getAllTermData(term): 

112 base = getBaseQuery(term.academicYear) 

113 

114 columns = ["Program Name", "Event Name", "Event Description", "Event Date", "Event Start Time", "Event End Time", "Event Location", 

115 "Food Provided", "Labor Only", "Training Event", "RSVP Required", "Service Event", "Engagement Event", "All Volunteer Training", 

116 "RSVP Limit", "Series #", "Is Repeating Event", "Contact Name", "Contact Email", 

117 "Student First Name", "Student Last Name", "Student Email", "Student B-Number", "Student Phone", "Student CPO", "Student Major", "Student Has Graduated", "Student Class Level", "Student Dietary Restrictions", 

118 "Hours Earned"] 

119 query = (base.select(Program.programName,Event.name, Event.description, Event.startDate, Event.timeStart, Event.timeEnd, Event.location, 

120 makeCase(Event.isFoodProvided), makeCase(Event.isLaborOnly), makeCase(Event.isTraining), makeCase(Event.isRsvpRequired), makeCase(Event.isService), makeCase(Event.isEngagement), makeCase(Event.isAllVolunteerTraining), 

121 Event.rsvpLimit, Event.seriesId, makeCase(Event.isRepeating), Event.contactName, Event.contactEmail, 

122 User.firstName, User.lastName, fn.CONCAT(User.username,'@berea.edu'), User.bnumber, User.phoneNumber,User.cpoNumber,User.major, makeCase(User.hasGraduated), User.rawClassLevel, User.dietRestriction, 

123 EventParticipant.hoursEarned) 

124 .where(Event.term == term)) 

125 

126 return (columns,query.tuples()) 

127 

128def volunteerMajorAndClass(academicYear, column, classLevel=False): 

129 base = getBaseQuery(academicYear) 

130 

131 columns = ["Major", "Count"] 

132 query = (base.select(Case(None, ((column.is_null(), "Unknown"),), column), fn.COUNT(fn.DISTINCT(EventParticipant.user_id)).alias('count')) 

133 .where(Event.isService == True) 

134 .group_by(column)) 

135 

136 if classLevel: 

137 columns = ["Class Level", "Count"] 

138 query = query.order_by(Case(None, ((column == "Freshman", 1), 

139 (column == "Sophomore", 2), 

140 (column == "Junior", 3), 

141 (column == "Senior", 4), 

142 (column == "Graduating", 5), 

143 (column == "Non-Degree", 6), 

144 (column.is_null(), 7)), 

145 8)) 

146 else: 

147 query = query.order_by(SQL("count").desc()) 

148 

149 return (columns, query.tuples()) 

150 

151 

152def repeatParticipantsPerProgram(academicYear): 

153 base = getBaseQuery(academicYear) 

154 

155 columns = ["Volunteer", "Program Name", "Event Count"] 

156 query = (base.select(fn.CONCAT(User.firstName, " ", User.lastName).alias('fullName'), 

157 Program.programName.alias("programName"), 

158 fn.COUNT(EventParticipant.event_id).alias('event_count')) 

159 .where(Event.isService == True) 

160 .group_by(User.firstName, User.lastName, Event.program) 

161 .having(fn.COUNT(EventParticipant.event_id) > 1) 

162 .order_by(Event.program, User.lastName)) 

163 

164 return (columns, query.tuples()) 

165 

166 

167def repeatParticipants(academicYear): 

168 base = getBaseQuery(academicYear) 

169 

170 columns = ["Number of Events", "Full Name", "Email", "B-Number"] 

171 query = (base.select(fn.COUNT(EventParticipant.user_id).alias('count'), 

172 fn.CONCAT(User.firstName, ' ', User.lastName), 

173 fn.CONCAT(EventParticipant.user_id,'@berea.edu'), 

174 User.bnumber) 

175 .group_by(User.firstName, User.lastName) 

176 .having(fn.COUNT(EventParticipant.user_id) > 1) 

177 .order_by(SQL("count").desc())) 

178 

179 return (columns, query.tuples()) 

180 

181 

182def getRetentionRate(academicYear): 

183 fallParticipationDict = termParticipation(getFallTerm(academicYear)) 

184 springParticipationDict = termParticipation(getSpringTerm(academicYear)) 

185 

186 retentionList = [] 

187 retentionRateDict = calculateRetentionRate(fallParticipationDict, springParticipationDict) 

188 for program, retentionRate in retentionRateDict.items(): 

189 retentionList.append((program, str(round(retentionRate * 100, 2)) + "%")) 

190 

191 columns = ["Program", "Retention Rate"] 

192 return (columns, retentionList) 

193 

194 

195def termParticipation(term): 

196 base = getBaseQuery(term.academicYear) 

197 

198 participationQuery = (base.select(Event.program, EventParticipant.user_id.alias('participant'), Program.programName.alias("programName")) 

199 .where(Event.term == term) 

200 .order_by(EventParticipant.user)) 

201 

202 programParticipationDict = defaultdict(list) 

203 for result in participationQuery.dicts(): 

204 programName = result['programName'] 

205 participant = result['participant'] 

206 programParticipationDict[programName].append(participant) 

207 

208 return dict(programParticipationDict) 

209 

210 

211def removeNullParticipants(participantList): 

212 return list(filter(lambda participant: participant, participantList)) 

213 

214 

215def calculateRetentionRate(fallDict, springDict): 

216 retentionDict = {} 

217 for program in fallDict: 

218 fallParticipants = set(removeNullParticipants(fallDict[program])) 

219 springParticipants = set(removeNullParticipants(springDict.get(program, []))) 

220 retentionRate = 0.0 

221 try: 

222 retentionRate = len(fallParticipants & springParticipants) / len(fallParticipants) 

223 except ZeroDivisionError: 

224 pass 

225 retentionDict[program] = retentionRate 

226 

227 return retentionDict 

228 

229def laborAttendanceByTerm(term): 

230 fullName = fn.CONCAT(User.firstName, ' ', User.lastName).alias('fullName') 

231 email = fn.CONCAT(User.username, '@berea.edu').alias('email') 

232 meetingsAttended = fn.COUNT(fn.DISTINCT(Event.id)).alias('meetingsAttended') 

233 

234 validEvent = ( 

235 (EventParticipant.event == Event.id) & 

236 (Event.term == term) & 

237 (Event.isLaborOnly == True) & 

238 (Event.deletionDate.is_null()) & 

239 (Event.isCanceled == False)) 

240 

241 CLTerm = Term.alias() 

242 laborMembers = ( 

243 CeltsLabor 

244 .select(fn.DISTINCT(CeltsLabor.user_id)) 

245 .join(CLTerm, on=(CeltsLabor.term == CLTerm.id)) 

246 .where( 

247 (CeltsLabor.term == term) | 

248 ((CLTerm.academicYear == term.academicYear) & (CeltsLabor.isAcademicYear == True)) 

249 )) 

250 

251 laborQuery = ( 

252 CeltsLabor 

253 .select(fullName, User.bnumber, email, meetingsAttended) 

254 .join(User) 

255 .switch(CeltsLabor) 

256 .join(EventParticipant, JOIN.LEFT_OUTER, on=(CeltsLabor.user == EventParticipant.user)) 

257 .join(Event, JOIN.LEFT_OUTER,on=validEvent) 

258 .where(CeltsLabor.user.in_(laborMembers)) 

259 .group_by(CeltsLabor.user)) 

260 

261 nonLaborQuery = ( 

262 EventParticipant 

263 .select(fullName, User.bnumber, email, meetingsAttended) 

264 .join(User) 

265 .switch(EventParticipant) 

266 .join(Event,on=validEvent) 

267 .where(EventParticipant.user.not_in(laborMembers)) 

268 .group_by(EventParticipant.user)) 

269 

270 query = laborQuery.union(nonLaborQuery).order_by(SQL('fullName')) 

271 columns = ("Full Name", "B-Number", "Email", "Meetings Attended") 

272 

273 return (columns, query.tuples()) 

274 

275def makeDataXls(sheetName, sheetData, workbook, sheetDesc=None): 

276 # assumes the length of the column titles matches the length of the data 

277 (columnTitles, dataTuples) = sheetData 

278 worksheet = workbook.add_worksheet(sheetName) 

279 bold = workbook.add_format({'bold': True}) 

280 

281 worksheet.write_string(0, 0, sheetName, bold) 

282 if sheetDesc: 

283 worksheet.write_string(1, 0, sheetDesc) 

284 

285 for column, title in enumerate(columnTitles): 

286 worksheet.write(3, column, title, bold) 

287 

288 for row, rowData in enumerate(dataTuples): 

289 for column, value in enumerate(rowData): 

290 # dates and times should use their text representation 

291 if isinstance(value, (datetime, date, time)): 

292 value = str(value) 

293 

294 worksheet.write(row + 4, column, value) 

295 

296 # set the width to the size of the text, with a maximum of 50 characters 

297 for column, title in enumerate(columnTitles): 

298 # put all of the data in each column into a list 

299 columnData = [title] + [rowData[column] for rowData in dataTuples] 

300 

301 # find the largest item in the list (and cut it off at 50) 

302 setColumnWidth = min(max(len(str(x)) for x in columnData),50) 

303 

304 worksheet.set_column(column, column, setColumnWidth + 3) 

305 

306 

307def createSpreadsheet(academicYear): 

308 filepath = f"{app.config['files']['base_path']}/volunteer_data_{academicYear}.xlsx" 

309 workbook = xlsxwriter.Workbook(filepath, {'in_memory': True}) 

310 

311 makeDataXls("Total Hours", totalHours(academicYear), workbook, sheetDesc=f"All participation hours for {academicYear}.") 

312 makeDataXls("Total Hours By Program", totalHoursByProgram(academicYear), workbook, sheetDesc=f"All participation hours by program for {academicYear}.") 

313 makeDataXls("Program Volunteers", volunteerProgramHours(academicYear), workbook, sheetDesc="Total program service hours for each volunteer.") 

314 makeDataXls("Volunteers By Major", volunteerMajorAndClass(academicYear, User.major), workbook, sheetDesc="All volunteers who participated in service events, by major.") 

315 makeDataXls("Volunteers By Class Level", volunteerMajorAndClass(academicYear, User.rawClassLevel, classLevel=True), workbook, sheetDesc="All volunteers who participated in service events, by class level. Our source for this data does not seem to be particularly accurate.") 

316 makeDataXls("Repeat Participants", repeatParticipants(academicYear), workbook, sheetDesc="Students who participated in multiple events, whether earning service hours or not.") 

317 makeDataXls("Unique Volunteers", getUniqueVolunteers(academicYear), workbook, sheetDesc=f"All students who participated in at least one service event during {academicYear}.") 

318 makeDataXls("Only All Volunteer Training", onlyCompletedAllVolunteer(academicYear), workbook, sheetDesc="Students who participated in an All Volunteer Training, but did not participate in any service events.") 

319 makeDataXls("Retention Rate By Semester", getRetentionRate(academicYear), workbook, sheetDesc="The percentage of students who participated in service events in the fall semester who also participated in a service event in the spring semester. Does not currently account for fall graduations.") 

320 

321 fallTerm = getFallTerm(academicYear) 

322 springTerm = getSpringTerm(academicYear) 

323 makeDataXls(f"Labor Attendance {fallTerm.description}", laborAttendanceByTerm(fallTerm), workbook,sheetDesc=f"Number of labor-only events attended in {fallTerm.description} for each labor student and non-labor attendees, including zero attendance (for labor students).") 

324 makeDataXls(f"Labor Attendance {springTerm.description}", laborAttendanceByTerm(springTerm), workbook, sheetDesc=f"Number of labor-only events attended in {springTerm.description} for each labor student and non-labor attendees, including zero attendance (for labor students).") 

325 

326 makeDataXls(fallTerm.description, getAllTermData(fallTerm), workbook, sheetDesc= "All event participation for the term, excluding deleted or canceled events.") 

327 makeDataXls(springTerm.description, getAllTermData(springTerm), workbook, sheetDesc="All event participation for the term, excluding deleted or canceled events.") 

328 

329 workbook.close() 

330 

331 return filepath