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

145 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2026-02-19 22:19 +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.eventParticipant import EventParticipant 

9from app.models.user import User 

10from app.models.program import Program 

11from app.models.event import Event 

12from app.models.term import Term 

13 

14### READ ME FIRST! ################################################################# 

15# 

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

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

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

19# be the way CELTS uses it. 

20# 

21#################################################################################### 

22 

23def getFallTerm(academicYear): 

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

25 

26def getSpringTerm(academicYear): 

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

28 

29 

30def getBaseQuery(academicYear): 

31 

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

33 

34 return (EventParticipant.select() 

35 .join(User).switch(EventParticipant) 

36 .join(Event) 

37 .join(Program).switch(Event) 

38 .join(Term) 

39 .where(Term.academicYear == academicYear, 

40 Event.deletionDate == None, 

41 Event.isCanceled == False) 

42 .order_by(Event.startDate)) 

43 

44 

45def getUniqueVolunteers(academicYear): 

46 base = getBaseQuery(academicYear) 

47 

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

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

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

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

52 

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

54 

55 

56def volunteerProgramHours(academicYear): 

57 base = getBaseQuery(academicYear) 

58 

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

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

61 fn.SUM(EventParticipant.hoursEarned), 

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

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

64 User.bnumber) 

65 .where(Event.isService == True) 

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

67 

68 return (columns, query.tuples()) 

69 

70def onlyCompletedAllVolunteer(academicYear): 

71 base = getBaseQuery(academicYear) 

72 base2 = getBaseQuery(academicYear) 

73 

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

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

76 

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

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

79 User.bnumber) 

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

81 

82 return (columns, query.tuples()) 

83 

84def totalHours(academicYear): 

85 base = getBaseQuery(academicYear) 

86 

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

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

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

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

91 

92 return (columns, query.tuples()) 

93 

94def totalHoursByProgram(academicYear): 

95 base = getBaseQuery(academicYear) 

96 

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

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

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

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

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

102 .group_by(Program.programName) 

103 .order_by(Program.programName)) 

104 

105 return (columns, query.tuples()) 

106 

107def makeCase(fieldname): 

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

109 

110def getAllTermData(term): 

111 base = getBaseQuery(term.academicYear) 

112 

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

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

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

116 "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", 

117 "Hours Earned"] 

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

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

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

121 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, 

122 EventParticipant.hoursEarned) 

123 .where(Event.term == term)) 

124 

125 return (columns,query.tuples()) 

126 

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

128 base = getBaseQuery(academicYear) 

129 

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

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

132 .where(Event.isService == True) 

133 .group_by(column)) 

134 

135 if classLevel: 

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

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

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

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

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

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

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

143 (column.is_null(), 7)), 

144 8)) 

145 else: 

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

147 

148 return (columns, query.tuples()) 

149 

150 

151def repeatParticipantsPerProgram(academicYear): 

152 base = getBaseQuery(academicYear) 

153 

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

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

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

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

158 .where(Event.isService == True) 

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

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

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

162 

163 return (columns, query.tuples()) 

164 

165 

166def repeatParticipants(academicYear): 

167 base = getBaseQuery(academicYear) 

168 

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

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

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

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

173 User.bnumber) 

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

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

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

177 

178 return (columns, query.tuples()) 

179 

180 

181def getRetentionRate(academicYear): 

182 fallParticipationDict = termParticipation(getFallTerm(academicYear)) 

183 springParticipationDict = termParticipation(getSpringTerm(academicYear)) 

184 

185 retentionList = [] 

186 retentionRateDict = calculateRetentionRate(fallParticipationDict, springParticipationDict) 

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

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

189 

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

191 return (columns, retentionList) 

192 

193 

194def termParticipation(term): 

195 base = getBaseQuery(term.academicYear) 

196 

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

198 .where(Event.term == term) 

199 .order_by(EventParticipant.user)) 

200 

201 programParticipationDict = defaultdict(list) 

202 for result in participationQuery.dicts(): 

203 programName = result['programName'] 

204 participant = result['participant'] 

205 programParticipationDict[programName].append(participant) 

206 

207 return dict(programParticipationDict) 

208 

209 

210def removeNullParticipants(participantList): 

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

212 

213 

214def calculateRetentionRate(fallDict, springDict): 

215 retentionDict = {} 

216 for program in fallDict: 

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

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

219 retentionRate = 0.0 

220 try: 

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

222 except ZeroDivisionError: 

223 pass 

224 retentionDict[program] = retentionRate 

225 

226 return retentionDict 

227 

228def laborAttendanceByTerm(academicYear): 

229 """Get labor students and their meeting attendance count for each term""" 

230 base = getBaseQuery(academicYear) 

231 

232 query = (base.select( 

233 fn.CONCAT(User.firstName, ' ', User.lastNAme).alias('fullName'), 

234 User.bnumber, 

235 User.email, 

236 Term.description, 

237 fn.COUNT(EventParticipant.event_id).alias('meetingsAttended'), 

238 ) 

239 .where(Event.isLaborOnly == True) 

240 .group_by(User.username, Term.description) 

241 .order_by(User.lastName, Term.description) 

242 ) 

243 

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

245 return (query.tuples()) 

246 

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

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

249 (columnTitles, dataTuples) = sheetData 

250 worksheet = workbook.add_worksheet(sheetName) 

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

252 

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

254 if sheetDesc: 

255 worksheet.write_string(1, 0, sheetDesc) 

256 

257 for column, title in enumerate(columnTitles): 

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

259 

260 for row, rowData in enumerate(dataTuples): 

261 for column, value in enumerate(rowData): 

262 # dates and times should use their text representation 

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

264 value = str(value) 

265 

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

267 

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

269 for column, title in enumerate(columnTitles): 

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

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

272 

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

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

275 

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

277 

278 

279def createSpreadsheet(academicYear): 

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

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

282 

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

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

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

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

287 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.") 

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

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

290 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.") 

291 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.") 

292 

293 fallTerm = getFallTerm(academicYear) 

294 springTerm = getSpringTerm(academicYear) 

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

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

297 

298 workbook.close() 

299 

300 return filepath