Coverage for app/logic/events.py: 93%
306 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-08-29 21:39 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2024-08-29 21:39 +0000
1from flask import url_for, g, session
2from peewee import DoesNotExist, fn, JOIN
3from dateutil import parser
4from datetime import timedelta, date, datetime
5from dateutil.relativedelta import relativedelta
6from werkzeug.datastructures import MultiDict
7from app.models import mainDB
8from app.models.user import User
9from app.models.event import Event
10from app.models.eventParticipant import EventParticipant
11from app.models.program import Program
12from app.models.term import Term
13from app.models.programBan import ProgramBan
14from app.models.interest import Interest
15from app.models.eventRsvp import EventRsvp
16from app.models.requirementMatch import RequirementMatch
17from app.models.certificationRequirement import CertificationRequirement
18from app.models.eventViews import EventView
20from app.logic.createLogs import createActivityLog, createRsvpLog
21from app.logic.utils import format24HourTime
22from app.logic.fileHandler import FileHandler
23from app.logic.certification import updateCertRequirementForEvent
25def cancelEvent(eventId):
26 """
27 Cancels an event.
28 """
29 event = Event.get_or_none(Event.id == eventId)
30 if event:
31 event.isCanceled = True
32 event.save()
34 program = event.program
35 createActivityLog(f"Canceled <a href= \"{url_for('admin.eventDisplay', eventId = event.id)}\" >{event.name}</a> for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.")
38def deleteEvent(eventId):
39 """
40 Deletes an event, if it is a recurring event, rename all following events
41 to make sure there is no gap in weeks.
42 """
43 event = Event.get_or_none(Event.id == eventId)
45 if event:
46 if event.recurringId:
47 recurringId = event.recurringId
48 recurringEvents = list(Event.select().where(Event.recurringId==recurringId).order_by(Event.id)) # orders for tests
49 eventDeleted = False
51 # once the deleted event is detected, change all other names to the previous event's name
52 for recurringEvent in recurringEvents:
53 if eventDeleted:
54 Event.update({Event.name:newEventName}).where(Event.id==recurringEvent.id).execute()
55 newEventName = recurringEvent.name
57 if recurringEvent == event:
58 newEventName = recurringEvent.name
59 eventDeleted = True
61 program = event.program
63 if program:
64 createActivityLog(f"Deleted \"{event.name}\" for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.")
65 else:
66 createActivityLog(f"Deleted a non-program event, \"{event.name}\", which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.")
68 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where(Event.id == event.id).execute()
71def deleteEventAndAllFollowing(eventId):
72 """
73 Deletes a recurring event and all the recurring events after it.
74 Modified to also apply to the case of events with multiple offerings
75 """
76 event = Event.get_or_none(Event.id == eventId)
77 if event:
78 if event.recurringId:
79 recurringId = event.recurringId
80 recurringSeries = list(Event.select(Event.id).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate)))
81 deletedEventList = [recurringEvent.id for recurringEvent in recurringSeries]
82 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate)).execute()
83 return deletedEventList
85def deleteAllRecurringEvents(eventId):
86 """
87 Deletes all recurring events.
88 Modified to also apply for events with multiple offerings
89 """
90 event = Event.get_or_none(Event.id == eventId)
91 if event:
92 if event.recurringId:
93 recurringId = event.recurringId
94 allRecurringEvents = list(Event.select(Event.id).where(Event.recurringId == recurringId).order_by(Event.startDate))
95 eventId = allRecurringEvents[0].id
96 return deleteEventAndAllFollowing(eventId)
100def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False):
101 """
102 Tries to save an event to the database:
103 Checks that the event data is valid and if it is, it continues to save the new
104 event to the database and adds files if there are any.
105 If it is not valid it will return a validation error.
107 Returns:
108 Created events and an error message.
109 """
111 # Manually set the value of RSVP Limit if it is and empty string since it is
112 # automatically changed from "" to 0
113 if eventData["rsvpLimit"] == "":
114 eventData["rsvpLimit"] = None
116 newEventData = preprocessEventData(eventData)
118 isValid, validationErrorMessage = validateNewEventData(newEventData)
120 if not isValid:
121 return False, validationErrorMessage
123 try:
124 events = saveEventToDb(newEventData, renewedEvent)
125 if attachmentFiles:
126 for event in events:
127 addFile = FileHandler(attachmentFiles, eventId=event.id)
128 addFile.saveFiles(saveOriginalFile=events[0])
129 return events, ""
130 except Exception as e:
131 print(f'Failed attemptSaveEvent() with Exception: {e}')
132 return False, e
134def saveEventToDb(newEventData, renewedEvent = False):
136 if not newEventData.get('valid', False) and not renewedEvent:
137 raise Exception("Unvalidated data passed to saveEventToDb")
140 isNewEvent = ('id' not in newEventData)
143 eventsToCreate = []
144 recurringSeriesId = None
145 multipleSeriesId = None
146 if (isNewEvent and newEventData['isRecurring']) and not renewedEvent:
147 eventsToCreate = calculateRecurringEventFrequency(newEventData)
148 recurringSeriesId = calculateNewrecurringId()
150 #temporarily applying the append for single events for now to tests
151 elif(isNewEvent and newEventData['isMultipleOffering']) and not renewedEvent:
152 eventsToCreate.append({'name': f"{newEventData['name']}",
153 'date':newEventData['startDate'],
154 "week":1})
155 multipleSeriesId = newEventData['multipleOfferingId']
157 else:
158 eventsToCreate.append({'name': f"{newEventData['name']}",
159 'date':newEventData['startDate'],
160 "week":1})
161 if renewedEvent:
162 recurringSeriesId = newEventData.get('recurringId')
163 eventRecords = []
164 for eventInstance in eventsToCreate:
165 with mainDB.atomic():
167 eventData = {
168 "term": newEventData['term'],
169 "name": eventInstance['name'],
170 "description": newEventData['description'],
171 "timeStart": newEventData['timeStart'],
172 "timeEnd": newEventData['timeEnd'],
173 "location": newEventData['location'],
174 "isFoodProvided" : newEventData['isFoodProvided'],
175 "isTraining": newEventData['isTraining'],
176 "isRsvpRequired": newEventData['isRsvpRequired'],
177 "isService": newEventData['isService'],
178 "startDate": eventInstance['date'],
179 "rsvpLimit": newEventData['rsvpLimit'],
180 "endDate": eventInstance['date'],
181 "contactEmail": newEventData['contactEmail'],
182 "contactName": newEventData['contactName']
183 }
185 # The three fields below are only relevant during event creation so we only set/change them when
186 # it is a new event.
187 if isNewEvent:
188 eventData['program'] = newEventData['program']
189 eventData['recurringId'] = recurringSeriesId
190 eventData['multipleOfferingId'] = multipleSeriesId
191 eventData["isAllVolunteerTraining"] = newEventData['isAllVolunteerTraining']
192 eventRecord = Event.create(**eventData)
193 else:
194 eventRecord = Event.get_by_id(newEventData['id'])
195 Event.update(**eventData).where(Event.id == eventRecord).execute()
197 if 'certRequirement' in newEventData and newEventData['certRequirement'] != "":
198 updateCertRequirementForEvent(eventRecord, newEventData['certRequirement'])
200 eventRecords.append(eventRecord)
201 return eventRecords
203def getStudentLedEvents(term):
204 studentLedEvents = list(Event.select(Event, Program)
205 .join(Program)
206 .where(Program.isStudentLed,
207 Event.term == term, Event.deletionDate == None)
208 .order_by(Event.startDate, Event.timeStart)
209 .execute())
211 programs = {}
213 for event in studentLedEvents:
214 programs.setdefault(event.program, []).append(event)
216 return programs
218def getUpcomingStudentLedCount(term, currentTime):
219 """
220 Return a count of all upcoming events for each student led program.
221 """
223 upcomingCount = (Program.select(Program.id, fn.COUNT(Event.id).alias("eventCount"))
224 .join(Event, on=(Program.id == Event.program_id))
225 .where(Program.isStudentLed,
226 Event.term == term, Event.deletionDate == None,
227 (Event.endDate > currentTime) | ((Event.endDate == currentTime) & (Event.timeEnd >= currentTime)),
228 Event.isCanceled == False)
229 .group_by(Program.id))
231 programCountDict = {}
233 for programCount in upcomingCount:
234 programCountDict[programCount.id] = programCount.eventCount
235 return programCountDict
237def getTrainingEvents(term, user):
238 """
239 The allTrainingsEvent query is designed to select and count eventId's after grouping them
240 together by id's of similiar value. The query will then return the event that is associated
241 with the most programs (highest count) by doing this we can ensure that the event being
242 returned is the All Trainings Event.
243 term: expected to be the ID of a term
244 user: expected to be the current user
245 return: a list of all trainings the user can view
246 """
247 trainingQuery = (Event.select(Event).distinct()
248 .join(Program, JOIN.LEFT_OUTER)
249 .where(Event.isTraining == True,
250 Event.term == term, Event.deletionDate == None)
251 .order_by(Event.isAllVolunteerTraining.desc(), Event.startDate, Event.timeStart))
253 hideBonner = (not user.isAdmin) and not (user.isStudent and user.isBonnerScholar)
254 if hideBonner:
255 trainingQuery = trainingQuery.where(Program.isBonnerScholars == False)
257 return list(trainingQuery.execute())
259def getBonnerEvents(term):
260 bonnerScholarsEvents = list(Event.select(Event, Program.id.alias("program_id"))
261 .join(Program)
262 .where(Program.isBonnerScholars,
263 Event.term == term, Event.deletionDate == None)
264 .order_by(Event.startDate, Event.timeStart)
265 .execute())
266 return bonnerScholarsEvents
268def getOtherEvents(term):
269 """
271 Get the list of the events not caught by other functions to be displayed in
272 the Other Events section of the Events List page.
273 :return: A list of Other Event objects
274 """
275 # Gets all events that are not associated with a program and are not trainings
276 # Gets all events that have a program but don't fit anywhere
278 otherEvents = list(Event.select(Event, Program)
279 .join(Program, JOIN.LEFT_OUTER)
280 .where(Event.term == term, Event.deletionDate == None,
281 Event.isTraining == False,
282 Event.isAllVolunteerTraining == False,
283 ((Program.isOtherCeltsSponsored) |
284 ((Program.isStudentLed == False) &
285 (Program.isBonnerScholars == False))))
286 .order_by(Event.startDate, Event.timeStart, Event.id)
287 .execute())
289 return otherEvents
291def getUpcomingEventsForUser(user, asOf=datetime.now(), program=None):
292 """
293 Get the list of upcoming events that the user is interested in as long
294 as they are not banned from the program that the event is a part of.
295 :param user: a username or User object
296 :param asOf: The date to use when determining future and past events.
297 Used in testing, defaults to the current timestamp.
298 :return: A list of Event objects
299 """
301 events = (Event.select().distinct()
302 .join(ProgramBan, JOIN.LEFT_OUTER, on=((ProgramBan.program == Event.program) & (ProgramBan.user == user)))
303 .join(Interest, JOIN.LEFT_OUTER, on=(Event.program == Interest.program))
304 .join(EventRsvp, JOIN.LEFT_OUTER, on=(Event.id == EventRsvp.event))
305 .where(Event.deletionDate == None, Event.startDate >= asOf,
306 (Interest.user == user) | (EventRsvp.user == user),
307 ProgramBan.user.is_null(True) | (ProgramBan.endDate < asOf)))
309 if program:
310 events = events.where(Event.program == program)
312 events = events.order_by(Event.startDate, Event.timeStart)
314 events_list = []
315 shown_recurring_event_list = []
316 shown_multiple_offering_event_list = []
318 # removes all recurring events except for the next upcoming one
319 for event in events:
320 if event.recurringId or event.multipleOfferingId:
321 if not event.isCanceled:
322 if event.recurringId not in shown_recurring_event_list:
323 events_list.append(event)
324 shown_recurring_event_list.append(event.recurringId)
325 if event.multipleOfferingId not in shown_multiple_offering_event_list:
326 events_list.append(event)
327 shown_multiple_offering_event_list.append(event.multipleOfferingId)
328 else:
329 if not event.isCanceled:
330 events_list.append(event)
332 return events_list
334def getParticipatedEventsForUser(user):
335 """
336 Get all the events a user has participated in.
337 :param user: a username or User object
338 :param asOf: The date to use when determining future and past events.
339 Used in testing, defaults to the current timestamp.
340 :return: A list of Event objects
341 """
343 participatedEvents = (Event.select(Event, Program.programName)
344 .join(Program, JOIN.LEFT_OUTER).switch()
345 .join(EventParticipant)
346 .where(EventParticipant.user == user,
347 Event.isAllVolunteerTraining == False)
348 .order_by(Event.startDate, Event.name))
350 allVolunteer = (Event.select(Event, "")
351 .join(EventParticipant)
352 .where(Event.isAllVolunteerTraining == True,
353 EventParticipant.user == user))
354 union = participatedEvents.union_all(allVolunteer)
355 unionParticipationWithVolunteer = list(union.select_from(union.c.id, union.c.programName, union.c.startDate, union.c.name).order_by(union.c.startDate, union.c.name).execute())
357 return unionParticipationWithVolunteer
359def validateNewEventData(data):
360 """
361 Confirm that the provided data is valid for an event.
363 Assumes the event data has been processed with `preprocessEventData`. NOT raw form data
365 Returns 3 values: (boolean success, the validation error message, the data object)
366 """
368 if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isService'], data['isRecurring'], data['isMultipleOffering']]:
369 return (False, "Raw form data passed to validate method. Preprocess first.")
371 if data['isRecurring'] and data['endDate'] < data['startDate']:
372 return (False, "Event start date is after event end date.")
374 if data['timeEnd'] <= data['timeStart']:
375 return (False, "Event end time must be after start time.")
377 # Validation if we are inserting a new event
378 if 'id' not in data:
380 sameEventList = list((Event.select().where((Event.name == data['name']) &
381 (Event.location == data['location']) &
382 (Event.startDate == data['startDate']) &
383 (Event.timeStart == data['timeStart'])).execute()))
385 sameEventListCopy = sameEventList.copy()
387 for event in sameEventListCopy:
388 if event.isCanceled or event.recurringId:
389 sameEventList.remove(event)
391 try:
392 Term.get_by_id(data['term'])
393 except DoesNotExist as e:
394 return (False, f"Not a valid term: {data['term']}")
395 if sameEventList:
396 return (False, "This event already exists")
398 data['valid'] = True
399 return (True, "All inputs are valid.")
401def calculateNewrecurringId():
402 """
403 Gets the highest recurring Id so that a new recurring Id can be assigned
404 """
405 recurringId = Event.select(fn.MAX(Event.recurringId)).scalar()
406 if recurringId:
407 return recurringId + 1
408 else:
409 return 1
410def calculateNewMultipleOfferingId():
411 """
412 Gets the highest recurring Id so that a new recurring Id can be assigned
413 """
414 multipleOfferingId = Event.select(fn.MAX(Event.multipleOfferingId)).scalar()
415 if multipleOfferingId:
416 return multipleOfferingId + 1
417 else:
418 return 1
420def getPreviousRecurringEventData(recurringId):
421 """
422 Joins the User db table and Event Participant db table so that we can get the information of a participant if they attended an event
423 """
424 previousEventVolunteers = (User.select(User).distinct()
425 .join(EventParticipant)
426 .join(Event)
427 .where(Event.recurringId==recurringId))
428 return previousEventVolunteers
430def getPreviousMultipleOfferingEventData(multipleOfferingId):
431 """
432 Joins the User db table and Event Participant db table so that we can get the information of a participant if they attended an event
433 """
434 previousEventVolunteers = (User.select(User).distinct()
435 .join(EventParticipant)
436 .join(Event)
437 .where(Event.multipleOfferingId == multipleOfferingId))
438 return previousEventVolunteers
440def calculateRecurringEventFrequency(event):
441 """
442 Calculate the events to create based on a recurring event start and end date. Takes a
443 dictionary of event data.
445 Assumes that the data has been processed with `preprocessEventData`. NOT raw form data.
447 Return a list of events to create from the event data.
448 """
449 if not isinstance(event['endDate'], date) or not isinstance(event['startDate'], date):
450 raise Exception("startDate and endDate must be datetime.date objects.")
452 if event['endDate'] == event['startDate']:
453 raise Exception("This event is not a recurring event")
455 return [ {'name': f"{event['name']} Week {counter+1}",
456 'date': event['startDate'] + timedelta(days=7*counter),
457 "week": counter+1}
458 for counter in range(0, ((event['endDate']-event['startDate']).days//7)+1)]
460def preprocessEventData(eventData):
461 """
462 Ensures that the event data dictionary is consistent before it reaches the template or event logic.
464 - dates should exist and be date objects if there is a value
465 - checkboxes should be True or False
466 - if term is given, convert it to a model object
467 - times should exist be strings in 24 hour format example: 14:40
468 - Look up matching certification requirement if necessary
469 """
470 ## Process checkboxes
471 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isRecurring', 'isMultipleOffering', 'isAllVolunteerTraining']
473 for checkBox in eventCheckBoxes:
474 if checkBox not in eventData:
475 eventData[checkBox] = False
476 else:
477 eventData[checkBox] = bool(eventData[checkBox])
479 ## Process dates
480 eventDates = ['startDate', 'endDate']
481 for eventDate in eventDates:
482 if eventDate not in eventData:
483 eventData[eventDate] = ''
484 elif type(eventData[eventDate]) is str and eventData[eventDate]:
485 eventData[eventDate] = parser.parse(eventData[eventDate])
486 elif not isinstance(eventData[eventDate], date):
487 eventData[eventDate] = ''
489 # If we aren't recurring, all of our events are single-day or mutliple offerings, which also have the same start and end date
490 if not eventData['isRecurring']:
491 eventData['endDate'] = eventData['startDate']
493 # Process terms
494 if 'term' in eventData:
495 try:
496 eventData['term'] = Term.get_by_id(eventData['term'])
497 except DoesNotExist:
498 eventData['term'] = ''
500 # Process requirement
501 if 'certRequirement' in eventData:
502 try:
503 eventData['certRequirement'] = CertificationRequirement.get_by_id(eventData['certRequirement'])
504 except DoesNotExist:
505 eventData['certRequirement'] = ''
506 elif 'id' in eventData:
507 # look up requirement
508 match = RequirementMatch.get_or_none(event=eventData['id'])
509 if match:
510 eventData['certRequirement'] = match.requirement
511 if 'timeStart' in eventData:
512 eventData['timeStart'] = format24HourTime(eventData['timeStart'])
514 if 'timeEnd' in eventData:
515 eventData['timeEnd'] = format24HourTime(eventData['timeEnd'])
517 return eventData
519def getTomorrowsEvents():
520 """Grabs each event that occurs tomorrow"""
521 tomorrowDate = date.today() + timedelta(days=1)
522 events = list(Event.select().where(Event.startDate==tomorrowDate))
523 return events
525def addEventView(viewer,event):
526 """This checks if the current user already viewed the event. If not, insert a recored to EventView table"""
527 if not viewer.isCeltsAdmin:
528 EventView.get_or_create(user = viewer, event = event)
530def getEventRsvpCountsForTerm(term):
531 """
532 Get all of the RSVPs for the events that exist in the term.
533 Returns a dictionary with the event id as the key and the amount of
534 current RSVPs to that event as the pair.
535 """
536 amount = (Event.select(Event, fn.COUNT(EventRsvp.event_id).alias('count'))
537 .join(EventRsvp, JOIN.LEFT_OUTER)
538 .where(Event.term == term, Event.deletionDate == None)
539 .group_by(Event.id))
541 amountAsDict = {event.id: event.count for event in amount}
543 return amountAsDict
545def getEventRsvpCount(eventId):
546 """
547 Returns the number of RSVP'd participants for a given eventId.
548 """
549 return len(EventRsvp.select().where(EventRsvp.event_id == eventId))
551def getCountdownToEvent(event, *, currentDatetime=None):
552 """
553 Given an event, this function returns a string that conveys the amount of time left
554 until the start of the event.
556 Note about dates:
557 Natural language is unintuitive. There are two major rules that govern how we discuss dates.
558 - If an event happens tomorrow but less than 24 hours away from us we still say that it happens
559 tomorrow with no mention of the hour.
560 - If an event happens tomorrow but more than 24 hours away from us, we'll count the number of days
561 and hours in actual time.
563 E.g. if the current time of day is greater than the event start's time of day, we give a number of days
564 relative to this morning and exclude all hours and minutes
566 On the other hand, if the current time of day is less or equal to the event's start of day we can produce
567 the real difference in days and hours without the aforementioned simplifying language.
568 """
570 if currentDatetime is None:
571 currentDatetime = datetime.now().replace(second=0, microsecond=0)
572 currentMorning = currentDatetime.replace(hour=0, minute=0)
574 eventStart = datetime.combine(event.startDate, event.timeStart)
575 eventEnd = datetime.combine(event.endDate, event.timeEnd)
577 if eventEnd < currentDatetime:
578 return "Already passed"
579 elif eventStart <= currentDatetime <= eventEnd:
580 return "Happening now"
582 timeUntilEvent = relativedelta(eventStart, currentDatetime)
583 calendarDelta = relativedelta(eventStart, currentMorning)
584 calendarYearsUntilEvent = calendarDelta.years
585 calendarMonthsUntilEvent = calendarDelta.months
586 calendarDaysUntilEvent = calendarDelta.days
588 yearString = f"{calendarYearsUntilEvent} year{'s' if calendarYearsUntilEvent > 1 else ''}"
589 monthString = f"{calendarMonthsUntilEvent} month{'s' if calendarMonthsUntilEvent > 1 else ''}"
590 dayString = f"{calendarDaysUntilEvent} day{'s' if calendarDaysUntilEvent > 1 else ''}"
591 hourString = f"{timeUntilEvent.hours} hour{'s' if timeUntilEvent.hours > 1 else ''}"
592 minuteString = f"{timeUntilEvent.minutes} minute{'s' if timeUntilEvent.minutes > 1 else ''}"
594 # Years until
595 if calendarYearsUntilEvent:
596 if calendarMonthsUntilEvent:
597 return f"{yearString} and {monthString}"
598 return f"{yearString}"
599 # Months until
600 if calendarMonthsUntilEvent:
601 if calendarDaysUntilEvent:
602 return f"{monthString} and {dayString}"
603 return f"{monthString}"
604 # Days until
605 if calendarDaysUntilEvent:
606 if eventStart.time() < currentDatetime.time():
607 if calendarDaysUntilEvent == 1:
608 return "Tomorrow"
609 return f"{dayString}"
610 if timeUntilEvent.hours:
611 return f"{dayString} and {hourString}"
612 return f"{dayString}"
613 # Hours until
614 if timeUntilEvent.hours:
615 if timeUntilEvent.minutes:
616 return f"{hourString} and {minuteString}"
617 return f"{hourString}"
618 # Minutes until
619 elif timeUntilEvent.minutes > 1:
620 return f"{minuteString}"
621 # Seconds until
622 return "<1 minute"
624def copyRsvpToNewEvent(priorEvent, newEvent):
625 """
626 Copies rvsps from priorEvent to newEvent
627 """
628 rsvpInfo = list(EventRsvp.select().where(EventRsvp.event == priorEvent['id']).execute())
630 for student in rsvpInfo:
631 newRsvp = EventRsvp(
632 user = student.user,
633 event = newEvent,
634 rsvpWaitlist = student.rsvpWaitlist
635 )
636 newRsvp.save()
637 numRsvps = len(rsvpInfo)
638 if numRsvps:
639 createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}")