Coverage for app/logic/participants.py: 58%
175 statements
« prev ^ index » next coverage.py v7.10.2, created at 2026-10-10 20:46 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2026-10-10 20:46 +0000
1from flask import g
2from peewee import fn, JOIN
3from playhouse.shortcuts import model_to_dict
4from datetime import date, datetime
5from app.logic.users import isEligibleForProgram
6from app.models.user import User
7from app.models.event import Event
8from app.models.term import Term
9from app.models.eventRsvp import EventRsvp
10from app.models.program import Program
11from app.models.programBan import ProgramBan
12from app.models.eventParticipant import EventParticipant
13from app.models.backgroundCheck import BackgroundCheck
14from app.logic.volunteers import getEventLengthInHours
15from app.logic.events import getEventRsvpCountsForTerm
16from app.logic.createLogs import createRsvpLog
17from collections import defaultdict
18from app import app
22def addBnumberAsParticipant(bnumber, eventId):
23 """
24 Accepts scan input and signs in the user. If user exists or is already
25 signed in will return user and login status
26 """
27 try:
28 kioskUser = User.get(User.bnumber == bnumber)
29 except Exception as e:
30 print(e)
31 return None, "does not exist"
33 event = Event.get_by_id(eventId)
34 if (ProgramBan.select().where(ProgramBan.user == kioskUser, ProgramBan.program == event.program, ProgramBan.endDate > datetime.now(), ProgramBan.unbanNote == None).exists()):
35 userStatus = "banned"
37 elif checkUserVolunteer(kioskUser, event):
38 userStatus = "already signed in"
40 else:
41 # Non-RSVP and RSVP event handling
42 userStatus = "success"
43 if event.isRsvpRequired:
44 # RSVP event: standard logic (RSVP before event, attend after)
45 if event.isPastStart:
46 totalHours = getEventLengthInHours(event.timeStart, event.timeEnd, event.startDate)
47 EventParticipant.create(user=kioskUser, event=event, hoursEarned=totalHours)
48 else:
49 if not checkUserRsvp(kioskUser, event):
50 currentRsvp = getEventRsvpCountsForTerm(event.term)
51 waitlist = currentRsvp[event.id] >= event.rsvpLimit if event.rsvpLimit is not None else False
52 EventRsvp.create(user=kioskUser, event=event, rsvpWaitlist=waitlist)
53 targetList = getTargetList(event, waitlist)
54 try:
55 if g.current_user.username == kioskUser.username:
56 createRsvpLog(event.id, f"{kioskUser.fullName} joined {targetList}.")
57 else:
58 createRsvpLog(event.id, f"Added {kioskUser.fullName} to {targetList}.")
59 except Exception:
60 pass
61 else:
62 # Non-RSVP event: scanner entry ALWAYS marks as attended regardless of timing
63 totalHours = getEventLengthInHours(event.timeStart, event.timeEnd, event.startDate)
64 EventParticipant.create(user=kioskUser, event=event, hoursEarned=totalHours)
66 return kioskUser, userStatus
68def checkUserRsvp(user, event):
69 return EventRsvp.select().where(EventRsvp.user==user, EventRsvp.event == event).exists()
71def checkUserVolunteer(user, event):
72 return EventParticipant.select().where(EventParticipant.user == user, EventParticipant.event == event).exists()
74def getTargetList(event, waitlist=False):
75 return "the waitlist" if waitlist else "the Invited list" if not event.isRsvpRequired else "the RSVP list"
77def addPersonToEvent(user, event):
78 """
79 Add a user to an event.
80 If the event is in the past, add the user as a volunteer (EventParticipant) including hours worked.
81 If the event is in the future, rsvp for the user (EventRsvp)
83 Returns True if the operation was successful, false otherwise
84 """
85 try:
86 volunteerExists = checkUserVolunteer(user, event)
87 rsvpExists = checkUserRsvp(user, event)
89 if event.isRsvpRequired:
90 # RSVP event logic
91 if event.isPastStart:
92 if not volunteerExists:
93 eventHours = getEventLengthInHours(event.timeStart, event.timeEnd, event.startDate)
94 EventParticipant.create(user = user, event = event, hoursEarned = eventHours)
95 try:
96 createRsvpLog(event.id, f"Marked {user.fullName} as attended.")
97 except Exception:
98 pass
99 else:
100 if not rsvpExists:
101 currentRsvp = getEventRsvpCountsForTerm(event.term)
102 waitlist = currentRsvp[event.id] >= event.rsvpLimit if event.rsvpLimit is not None else 0
103 EventRsvp.create(user = user, event = event, rsvpWaitlist = waitlist)
104 targetList = "the waitlist" if waitlist else "the RSVP list"
105 if g.current_user.username == user.username:
106 createRsvpLog(event.id, f"{user.fullName} joined {targetList}.")
107 else:
108 createRsvpLog(event.id, f"Added {user.fullName} to {targetList}.")
109 else:
110 # Non-RSVP event logic
111 if event.isPastStart:
112 # After event: create EventParticipant (attended)
113 if not volunteerExists:
114 eventHours = getEventLengthInHours(event.timeStart, event.timeEnd, event.startDate)
115 EventParticipant.create(user = user, event = event, hoursEarned = eventHours)
116 try:
117 createRsvpLog(event.id, f"Marked {user.fullName} as attended.")
118 except Exception:
119 pass
120 else:
121 # Before event: create EventRsvp (invited status)
122 if not rsvpExists:
123 EventRsvp.create(user = user, event = event, rsvpWaitlist = False)
125 if volunteerExists or rsvpExists:
126 return "already in"
127 except Exception as e:
128 print(e)
129 return False
131 return True
133def unattendedRequiredEvents(program, user):
134 # Check for events that are prerequisite for program
135 requiredEvents = (Event.select(Event)
136 .where(Event.isTraining == True, Event.program == program))
138 if requiredEvents:
139 attendedRequiredEventsList = []
140 for event in requiredEvents:
141 attendedRequirement = (EventParticipant.select()
142 .join(User)
143 .where(EventParticipant.user == User.username, EventParticipant.event == event))
144 if not attendedRequirement:
145 attendedRequiredEventsList.append(event.name)
146 if attendedRequiredEventsList is not None:
147 return attendedRequiredEventsList
148 else:
149 return []
152def getEventParticipants(event):
153 eventParticipants = (EventParticipant.select(EventParticipant, User)
154 .join(User)
155 .where(EventParticipant.event == event))
157 return [p for p in eventParticipants]
159def getParticipationStatusForTrainings(program, userList, term, includeFutureEvents = True):
160 """
161 This function returns a dictionary of all trainings for a program and
162 whether the current user participated in them.
164 :returns: trainings for program and if the user participated
165 """
166 isRelevantTraining = ((Event.isAllVolunteerTraining | Event.isCeltsTraining | ((Event.isTraining) & (Event.program == program))) &
167 (Event.term.academicYear == term.academicYear))
168 programTrainings = (Event.select(Event, Term, EventParticipant, EventRsvp)
169 .join(EventParticipant, JOIN.LEFT_OUTER).switch()
170 .join(EventRsvp, JOIN.LEFT_OUTER).switch()
171 .join(Term)
172 .where(isRelevantTraining, (Event.isCanceled != True)).order_by(Event.startDate))
173 if not includeFutureEvents:
174 programTrainings = programTrainings.where(
175 (Event.startDate < datetime.now().date()) |
176 ((Event.startDate == datetime.now().date()) & (Event.timeStart <= datetime.now().time())))
177 # Create a dictionary where the keys are trainings and values are a set of those who attended
178 trainingData = defaultdict(set)
179 for training in programTrainings:
180 try:
181 if training.isPastStart:
182 trainingData[training].add(training.eventparticipant.user_id)
183 else: # The training has yet to happen
184 trainingData[training].add(training.eventrsvp.user_id)
185 except AttributeError:
186 pass
187 # Create a dictionary binding usernames to a list of [training, hasAttended] pairs. The tuples consist of the training (event object) and whether or not they attended it (bool)
189 # Necessarily complex algorithm to merge the attendances of trainings which have the same name
190 # Structure of userParticipationStatus for a single user:
191 # {user.username: {training1.name: [EventObject, hasAttended], training2.name: [EventObject, hasAttended]}, ...}
192 userParticipationStatus = {user.username: {} for user in userList}
193 for training, attendeeList in trainingData.items():
194 for user in userList:
195 if training.name not in userParticipationStatus[user.username] or user.username in attendeeList:
196 userParticipationStatus[user.username][training.name] = [training, user.username in attendeeList]
197 if includeFutureEvents:
198 return {user.username: list(userParticipationStatus[user.username].values()) for user in userList}
199 else:
200 return {user: list(userParticipationStatus[user.username].values()) for user in userList}
202def getTrainingsForInterestedParticipants(programID, interestedUsers):
203 """
204 Takes in a programID and a list of interested users, and returns all of the trainings and background checks they have completed.
205 Returns a nested dictionary which looks like the following:
206 {'userID1': {'userObj': <User>,
207 'allVolunteer': True,
208 'programSpecific': False,
209 'bgCheck': '0/22/2026',
210 'eligible': True,
211 'star': False
212 },
213 'userID2': {'userObj': <User>,
214 'allVolunteer': True,
215 'programSpecific': True,
216 'bgCheck': '0/22/2026',
217 'eligible': True,
218 'star': True
219 }
220 }
222 Gracefully handles multiple trainings of the same type (e.g., two All Volunteers Trainings)
223 """
224 trainedUsers = getParticipationStatusForTrainings(programID, interestedUsers, g.current_term, includeFutureEvents = False)
225 now = datetime.now()
226 bannedUsers = list(User
227 .select(User.username)
228 .join(ProgramBan)
229 .where(ProgramBan.program == programID,
230 ProgramBan.endDate > now,
231 ProgramBan.unbanNote == None,
232 User.username << [user.username for user in interestedUsers]))
233 bgCheckSubmitted = (User.select(User.username, BackgroundCheck.dateCompleted)
234 .join(BackgroundCheck)
235 .where(BackgroundCheck.user == User.username,
236 BackgroundCheck.deletionDate.is_null())
237 .distinct())
238 trainedAndInterested = {}
239 for interestedUser in interestedUsers:
240 if interestedUser in trainedUsers:
241 trainedAndInterested[interestedUser.username] = {}
242 trainedAndInterested[interestedUser.username]["userObj"] = interestedUser
243 trainedAndInterested[interestedUser.username]['allVolunteer'] = False
244 trainedAndInterested[interestedUser.username]['programSpecific'] = False
245 trainedAndInterested[interestedUser.username]['bgCheck'] = "Not submitted"
246 trainedAndInterested[interestedUser.username]["eligible"] = True
247 trainedAndInterested[interestedUser.username]["star"] = False
249 # Go through the trainings
250 for event in trainedUsers[interestedUser]:
251 if not event[1]: # they didn't attend this training
252 continue
253 elif event[0].isAllVolunteerTraining or event[0].isCeltsTraining: # They attended AVT or ACT
254 trainedAndInterested[interestedUser.username]["allVolunteer"] = True
255 elif event[0].isTraining: # They attended the Program-specific training
256 trainedAndInterested[interestedUser.username]["programSpecific"] = True
257 # They are banned
258 if interestedUser in bannedUsers:
259 trainedAndInterested[interestedUser.username]["eligible"] = False
260 # They submitted their background check
261 if interestedUser in bgCheckSubmitted:
262 trainedAndInterested[interestedUser.username]['bgCheck'] = "Submitted"
264 # NOTE: Handbook signature already tracked inside the user object
266 # Give them a star if they have met all the requirements
267 if ( trainedAndInterested[interestedUser.username]["allVolunteer"] and
268 trainedAndInterested[interestedUser.username]["programSpecific"] and
269 trainedAndInterested[interestedUser.username]["eligible"] and
270 trainedAndInterested[interestedUser.username]['bgCheck'] == "Submitted" and
271 trainedAndInterested[interestedUser.username]['userObj'].lastHandbookSignature is not None and
272 trainedAndInterested[interestedUser.username]['userObj'].signatureTerm.academicYear == g.current_term.academicYear):
273 trainedAndInterested[interestedUser.username]["star"] = True
275 return trainedAndInterested
277def getParticipantsForProgramForAY(program, academicYear):
278 participants = (User.select()
279 .join(EventParticipant)
280 .join(Event)
281 .join(Program)
282 .switch(Event)
283 .join(Term)
284 .where(Program.id == program,
285 Term.academicYear == academicYear,
286 User.hasGraduated == False,
287 EventParticipant.hoursEarned > 0,
288 # Only include currently enrolled undergraduate students.
289 # This prevents alumni and past participants from appearing in the older reports.
290 User.rawClassLevel.in_(["Freshman", "Sophomore", "Junior", "Senior"]))
291 .distinct()
292 )
293 return participants
296def sortParticipantsByStatus(event):
297 """
298 Takes in an event object, queries all participants, and then filters those
299 participants by their attendee status.
301 return: a list of participants who didn't attend, a list of participants who are waitlisted,
302 a list of participants who attended, and a list of all participants who have some status for the
303 event.
304 """
305 eventParticipants = getEventParticipants(event)
307 # get all RSVPs for event and filter out those that did not attend into separate list
308 eventRsvpData = list(EventRsvp.select(EventRsvp, User).join(User).where(EventRsvp.event==event).order_by(EventRsvp.rsvpTime))
309 eventNonAttendedData = [rsvp for rsvp in eventRsvpData if rsvp.user not in eventParticipants]
311 if event.isPastStart:
312 eventVolunteerData = eventParticipants
314 # if the event date has passed disregard the waitlist
315 eventWaitlistData = []
316 else:
317 # if rsvp is required for the event, grab all volunteers that are in the waitlist
318 eventWaitlistData = [volunteer for volunteer in (eventParticipants + eventRsvpData) if volunteer.rsvpWaitlist and event.isRsvpRequired]
320 # put all participants and non-waitlisted RSVPs into the volunteer data
321 eventVolunteerData = [volunteer for volunteer in (eventParticipants + eventNonAttendedData) if volunteer not in eventWaitlistData]
322 eventNonAttendedData = []
324 return eventNonAttendedData, eventWaitlistData, eventVolunteerData, eventParticipants
326def hasGoneToTraining(participant, term):
327 """
328 Taken in a User object, and returns which training (specifically, All volunteers training or All CELTS labor training) they attended for this term.
329 This is necessary for delivering the correct handbook to the student for signing.
331 return: A single event object of, in this order of precedence:
332 1) the All Celts training, if they attended,
333 2) the All Volunteers training, if they attended,
334 3) None
335 """
336 attended = (EventParticipant.select()
337 .join(User)
338 .switch(EventParticipant)
339 .join(Event)
340 .join(Term)
341 .where(User.username == participant.username,
342 Term.id == term.id,
343 Event.isAllVolunteerTraining | Event.isCeltsTraining)
344 .order_by(Event.isCeltsTraining)
345 )
346 if not attended:
347 return None
348 attended = attended[-1] if len(attended) > 1 else attended[0]
349 return attended.event