Coverage for app/logic/events.py: 95%
294 statements
« prev ^ index » next coverage.py v7.2.7, created at 2025-01-29 15:39 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2025-01-29 15: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
7import json
8from app.models import mainDB
9from app.models.user import User
10from app.models.event import Event
11from app.models.eventParticipant import EventParticipant
12from app.models.program import Program
13from app.models.term import Term
14from app.models.programBan import ProgramBan
15from app.models.interest import Interest
16from app.models.eventRsvp import EventRsvp
17from app.models.requirementMatch import RequirementMatch
18from app.models.certificationRequirement import CertificationRequirement
19from app.models.eventViews import EventView
21from app.logic.createLogs import createActivityLog, createRsvpLog
22from app.logic.utils import format24HourTime
23from app.logic.fileHandler import FileHandler
24from app.logic.certification import updateCertRequirementForEvent
26def cancelEvent(eventId):
27 """
28 Cancels an event.
29 """
30 event = Event.get_or_none(Event.id == eventId)
31 if event:
32 event.isCanceled = True
33 event.save()
35 program = event.program
36 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')}.")
38#NEEDS FIXING: process not working properly for repeating events when two events are deleted consecutively
39def deleteEvent(eventId):
40 """
41 Deletes an event, if it is a repeating event, rename all following events
42 to make sure there is no gap in weeks.
44 """
45 event = Event.get_or_none(Event.id == eventId)
47 if event:
48 if event.isRepeating:
49 seriesId = event.seriesId
50 repeatingEvents = list(Event.select().where(Event.seriesId==seriesId).order_by(Event.id)) # orders for tests
51 eventDeleted = False
52 # once the deleted event is detected, change all other names to the previous event's name
53 for repeatingEvent in repeatingEvents:
54 if eventDeleted:
55 Event.update({Event.name:newEventName}).where(Event.id==repeatingEvent.id).execute()
56 newEventName = repeatingEvent.name
58 if repeatingEvent == event:
59 newEventName = repeatingEvent.name
60 eventDeleted = True
62 program = event.program
64 if program:
65 createActivityLog(f"Deleted \"{event.name}\" for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.")
66 else:
67 createActivityLog(f"Deleted a non-program event, \"{event.name}\", which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.")
69 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where(Event.id == event.id).execute()
71def deleteEventAndAllFollowing(eventId):
72 """
73 Deletes an event in the series and all events after it
75 """
76 event = Event.get_or_none(Event.id == eventId)
77 if event:
78 if event.seriesId:
79 seriesId = event.seriesId
80 eventSeries = list(Event.select(Event.id).where((Event.seriesId == seriesId) & (Event.startDate >= event.startDate)))
81 deletedEventList = [event.id for event in eventSeries]
82 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where((Event.seriesId == seriesId) & (Event.startDate >= event.startDate)).execute()
83 return deletedEventList
85def deleteAllEventsInSeries(eventId):
86 """
87 Deletes all events in a series by getting the first event in the series and calling deleteEventAndAllFollowing().
89 """
90 event = Event.get_or_none(Event.id == eventId)
91 if event:
92 if event.seriesId:
93 seriesId = event.seriesId
94 allSeriesEvents = list(Event.select(Event.id).where(Event.seriesId == seriesId).order_by(Event.startDate))
95 eventId = allSeriesEvents[0].id
96 return deleteEventAndAllFollowing(eventId)
97 else:
98 raise ValueError(f"Event with id {event.id} does not belong to a series (seriesId is None).")
100def attemptSaveMultipleOfferings(eventData, attachmentFiles = None):
101 """
102 Tries to save an event with multiple offerings to the database:
103 Creates separate event data inheriting from the original eventData
104 with the specifics of each offering.
105 Calls attemptSaveEvent on each of the newly created datum
106 If any data is not valid it will return a validation error.
108 Returns:
109 allSavesWereSuccessful : bool | Whether or not all offering saves were successful
110 savedOfferings : List[event] | A list of event objects holding all offerings that were saved. If allSavesWereSuccessful is False then this list will be empty.
111 failedSavedOfferings : List[(int, str), ...] | Tuples containing the indicies of failed saved offerings and the associated validation error message.
112 """
113 savedOfferings = []
114 failedSavedOfferings = []
115 allSavesWereSuccessful = True
117 seriesId = calculateNewSeriesId()
119 # Create separate event data inheriting from the original eventData
120 seriesData = eventData.get('seriesData')
121 isRepeating = bool(eventData.get('isRepeating'))
122 with mainDB.atomic() as transaction:
123 for index, event in enumerate(seriesData):
124 eventInfo = eventData.copy()
125 eventInfo.update({
126 'name': event['eventName'],
127 'startDate': event['eventDate'],
128 'timeStart': event['startTime'],
129 'timeEnd': event['endTime'],
130 'seriesId': seriesId,
131 'isRepeating': bool(isRepeating)
132 })
133 # Try to save each offering
134 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles)
135 if validationErrorMessage:
136 failedSavedOfferings.append((index, validationErrorMessage))
137 allSavesWereSuccessful = False
138 else:
139 savedEvent = savedEvents[0]
140 savedOfferings.append(savedEvent)
141 if not allSavesWereSuccessful:
142 savedOfferings = []
143 transaction.rollback()
145 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings
148def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False):
149 """
150 Tries to save an event to the database:
151 Checks that the event data is valid and if it is, it continues to save the new
152 event to the database and adds files if there are any.
153 If it is not valid it will return a validation error.
155 Returns:
156 The saved event, created events and an error message if an error occurred.
157 """
159 # Manually set the value of RSVP Limit if it is and empty string since it is
160 # automatically changed from "" to 0
161 if eventData["rsvpLimit"] == "":
162 eventData["rsvpLimit"] = None
164 newEventData = preprocessEventData(eventData)
166 isValid, validationErrorMessage = validateNewEventData(newEventData)
167 if not isValid:
168 return [], validationErrorMessage
170 events = saveEventToDb(newEventData, renewedEvent)
171 if attachmentFiles:
172 for event in events:
173 addFile = FileHandler(attachmentFiles, eventId=event.id)
174 addFile.saveFiles(saveOriginalFile=events[0])
175 return events, ""
178def saveEventToDb(newEventData, renewedEvent = False):
180 if not newEventData.get('valid', False) and not renewedEvent:
181 raise Exception("Unvalidated data passed to saveEventToDb")
183 isNewEvent = ('id' not in newEventData)
185 eventRecords = []
186 with mainDB.atomic():
188 eventData = {
189 "term": newEventData['term'],
190 "name": newEventData['name'],
191 "description": newEventData['description'],
192 "timeStart": newEventData['timeStart'],
193 "timeEnd": newEventData['timeEnd'],
194 "location": newEventData['location'],
195 "isFoodProvided" : newEventData['isFoodProvided'],
196 "isTraining": newEventData['isTraining'],
197 "isEngagement": newEventData['isEngagement'],
198 "isRsvpRequired": newEventData['isRsvpRequired'],
199 "isService": newEventData['isService'],
200 "startDate": newEventData['startDate'],
201 "rsvpLimit": newEventData['rsvpLimit'],
202 "endDate": newEventData['startDate'],
203 "contactEmail": newEventData['contactEmail'],
204 "contactName": newEventData['contactName']
205 }
207 # The three fields below are only relevant during event creation so we only set/change them when
208 # it is a new event.
209 if isNewEvent:
210 eventData['program'] = newEventData['program']
211 eventData['seriesId'] = newEventData.get('seriesId')
212 eventData['isRepeating'] = bool(newEventData.get('isRepeating'))
213 eventData["isAllVolunteerTraining"] = newEventData['isAllVolunteerTraining']
214 eventRecord = Event.create(**eventData)
215 else:
216 eventRecord = Event.get_by_id(newEventData['id'])
217 Event.update(**eventData).where(Event.id == eventRecord).execute()
219 if 'certRequirement' in newEventData and newEventData['certRequirement'] != "":
220 updateCertRequirementForEvent(eventRecord, newEventData['certRequirement'])
222 eventRecords.append(eventRecord)
223 return eventRecords
225def getStudentLedEvents(term):
226 studentLedEvents = list(Event.select(Event, Program)
227 .join(Program)
228 .where(Program.isStudentLed,
229 Event.term == term, Event.deletionDate == None)
230 .order_by(Event.startDate, Event.timeStart)
231 .execute())
233 programs = {}
235 for event in studentLedEvents:
236 programs.setdefault(event.program, []).append(event)
238 return programs
240def getUpcomingStudentLedCount(term, currentTime):
241 """
242 Return a count of all upcoming events for each student led program.
243 """
245 upcomingCount = (Program.select(Program.id, fn.COUNT(Event.id).alias("eventCount"))
246 .join(Event, on=(Program.id == Event.program_id))
247 .where(Program.isStudentLed,
248 Event.term == term, Event.deletionDate == None,
249 (Event.endDate > currentTime) | ((Event.endDate == currentTime) & (Event.timeEnd >= currentTime)),
250 Event.isCanceled == False)
251 .group_by(Program.id))
253 programCountDict = {}
255 for programCount in upcomingCount:
256 programCountDict[programCount.id] = programCount.eventCount
257 return programCountDict
259def getTrainingEvents(term, user):
260 """
261 The allTrainingsEvent query is designed to select and count eventId's after grouping them
262 together by id's of similiar value. The query will then return the event that is associated
263 with the most programs (highest count) by doing this we can ensure that the event being
264 returned is the All Trainings Event.
265 term: expected to be the ID of a term
266 user: expected to be the current user
267 return: a list of all trainings the user can view
268 """
269 trainingQuery = (Event.select(Event).distinct()
270 .join(Program, JOIN.LEFT_OUTER)
271 .where(Event.isTraining == True,
272 Event.term == term, Event.deletionDate == None)
273 .order_by(Event.isAllVolunteerTraining.desc(), Event.startDate, Event.timeStart))
275 hideBonner = (not user.isAdmin) and not (user.isStudent and user.isBonnerScholar)
276 if hideBonner:
277 trainingQuery = trainingQuery.where(Program.isBonnerScholars == False)
279 return list(trainingQuery.execute())
281def getBonnerEvents(term):
282 bonnerScholarsEvents = list(Event.select(Event, Program.id.alias("program_id"))
283 .join(Program)
284 .where(Program.isBonnerScholars,
285 Event.term == term, Event.deletionDate == None)
286 .order_by(Event.startDate, Event.timeStart)
287 .execute())
288 return bonnerScholarsEvents
290def getOtherEvents(term):
291 """
293 Get the list of the events not caught by other functions to be displayed in
294 the Other Events section of the Events List page.
295 :return: A list of Other Event objects
296 """
297 # Gets all events that are not associated with a program and are not trainings
298 # Gets all events that have a program but don't fit anywhere
300 otherEvents = list(Event.select(Event, Program)
301 .join(Program, JOIN.LEFT_OUTER)
302 .where(Event.term == term, Event.deletionDate == None,
303 Event.isTraining == False,
304 Event.isAllVolunteerTraining == False,
305 ((Program.isOtherCeltsSponsored) |
306 ((Program.isStudentLed == False) &
307 (Program.isBonnerScholars == False))))
308 .order_by(Event.startDate, Event.timeStart, Event.id)
309 .execute())
311 return otherEvents
313def getUpcomingEventsForUser(user, asOf=datetime.now(), program=None):
314 """
315 Get the list of upcoming events that the user is interested in as long
316 as they are not banned from the program that the event is a part of.
317 :param user: a username or User object
318 :param asOf: The date to use when determining future and past events.
319 Used in testing, defaults to the current timestamp.
320 :return: A list of Event objects
321 """
323 events = (Event.select().distinct()
324 .join(ProgramBan, JOIN.LEFT_OUTER, on=((ProgramBan.program == Event.program) & (ProgramBan.user == user)))
325 .join(Interest, JOIN.LEFT_OUTER, on=(Event.program == Interest.program))
326 .join(EventRsvp, JOIN.LEFT_OUTER, on=(Event.id == EventRsvp.event))
327 .where(Event.deletionDate == None, Event.startDate >= asOf,
328 (Interest.user == user) | (EventRsvp.user == user),
329 ProgramBan.user.is_null(True) | (ProgramBan.endDate < asOf)))
331 if program:
332 events = events.where(Event.program == program)
334 events = events.order_by(Event.startDate, Event.timeStart)
336 eventsList = []
337 seriesEventsList = []
339 # removes all events in series except for the next upcoming one
340 for event in events:
341 if event.seriesId:
342 if not event.isCanceled:
343 if event.seriesId not in seriesEventsList:
344 eventsList.append(event)
345 seriesEventsList.append(event.seriesId)
346 else:
347 if not event.isCanceled:
348 eventsList.append(event)
350 return eventsList
352def getParticipatedEventsForUser(user):
353 """
354 Get all the events a user has participated in.
355 :param user: a username or User object
356 :param asOf: The date to use when determining future and past events.
357 Used in testing, defaults to the current timestamp.
358 :return: A list of Event objects
359 """
361 participatedEvents = (Event.select(Event, Program.programName)
362 .join(Program, JOIN.LEFT_OUTER).switch()
363 .join(EventParticipant)
364 .where(EventParticipant.user == user,
365 Event.isAllVolunteerTraining == False)
366 .order_by(Event.startDate, Event.name))
368 allVolunteer = (Event.select(Event, "")
369 .join(EventParticipant)
370 .where(Event.isAllVolunteerTraining == True,
371 EventParticipant.user == user))
372 union = participatedEvents.union_all(allVolunteer)
373 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())
375 return unionParticipationWithVolunteer
377def validateNewEventData(data):
378 """
379 Confirm that the provided data is valid for an event.
381 Assumes the event data has been processed with `preprocessEventData`. NOT raw form data
383 Returns 3 values: (boolean success, the validation error message, the data object)
384 """
386 if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isEngagement'], data['isService'], data['isRepeating']]:
387 return (False, "Raw form data passed to validate method. Preprocess first.")
389 if data['timeEnd'] <= data['timeStart']:
390 return (False, "Event end time must be after start time.")
392 # Validation if we are inserting a new event
393 if 'id' not in data:
395 sameEventList = list((Event.select().where((Event.name == data['name']) &
396 (Event.location == data['location']) &
397 (Event.startDate == data['startDate']) &
398 (Event.timeStart == data['timeStart'])).execute()))
400 sameEventListCopy = sameEventList.copy()
402 for event in sameEventListCopy:
403 if event.isCanceled or (event.seriesId and event.isRepeating):
404 sameEventList.remove(event)
406 try:
407 Term.get_by_id(data['term'])
408 except DoesNotExist as e:
409 return (False, f"Not a valid term: {data['term']}")
410 if sameEventList:
411 return (False, "This event already exists")
413 data['valid'] = True
414 return (True, "All inputs are valid.")
416def calculateNewSeriesId():
417 """
418 Gets the max series ID so that new seriesId can be assigned.
419 """
420 maxSeriesId = Event.select(fn.MAX(Event.seriesId)).scalar()
421 if maxSeriesId:
422 return maxSeriesId + 1
423 return 1
425def getPreviousSeriesEventData(seriesId):
426 """
427 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.
429 """
430 previousEventVolunteers = (User.select(User).distinct()
431 .join(EventParticipant)
432 .join(Event)
433 .where(Event.seriesId==seriesId))
434 return previousEventVolunteers
436def getRepeatingEventsData(eventData):
437 """
438 Calculate the events to create based on a repeating event start and end date. Takes a
439 dictionary of event data.
441 Assumes that the data has been processed with `preprocessEventData`. NOT raw form data.
443 Return a list of events to create from the event data.
444 """
446 return [ {'name': f"{eventData['name']} Week {counter+1}",
447 'date': eventData['startDate'] + timedelta(days=7*counter),
448 "week": counter+1}
449 for counter in range(0, ((eventData['endDate']-eventData['startDate']).days//7)+1)]
451def preprocessEventData(eventData):
452 """
453 Ensures that the event data dictionary is consistent before it reaches the template or event logic.
455 - dates should exist and be date objects if there is a value
456 - checkboxes should be True or False
457 - if term is given, convert it to a model object
458 - times should exist be strings in 24 hour format example: 14:40
459 - seriesData should be a JSON string
460 - Look up matching certification requirement if necessary
461 """
462 ## Process checkboxes
463 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isEngagement', 'isRepeating', 'isAllVolunteerTraining']
465 for checkBox in eventCheckBoxes:
466 if checkBox not in eventData:
467 eventData[checkBox] = False
468 else:
469 eventData[checkBox] = bool(eventData[checkBox])
471 ## Process dates
472 eventDates = ['startDate', 'endDate']
473 for eventDate in eventDates:
474 if eventDate not in eventData: # There is no date given
475 eventData[eventDate] = ''
476 elif type(eventData[eventDate]) is str and eventData[eventDate]: # The date is a nonempty string
477 eventData[eventDate] = parser.parse(eventData[eventDate])
478 elif not isinstance(eventData[eventDate], date): # The date is not a date object
479 eventData[eventDate] = ''
481 # Process seriesData
482 if 'seriesData' not in eventData:
483 eventData['seriesData'] = json.dumps([])
485 # Process terms
486 if 'term' in eventData:
487 try:
488 eventData['term'] = Term.get_by_id(eventData['term'])
489 except DoesNotExist:
490 eventData['term'] = ''
492 # Process requirement
493 if 'certRequirement' in eventData:
494 try:
495 eventData['certRequirement'] = CertificationRequirement.get_by_id(eventData['certRequirement'])
496 except DoesNotExist:
497 eventData['certRequirement'] = ''
498 elif 'id' in eventData:
499 # look up requirement
500 match = RequirementMatch.get_or_none(event=eventData['id'])
501 if match:
502 eventData['certRequirement'] = match.requirement
503 if 'timeStart' in eventData:
504 eventData['timeStart'] = format24HourTime(eventData['timeStart'])
506 if 'timeEnd' in eventData:
507 eventData['timeEnd'] = format24HourTime(eventData['timeEnd'])
509 return eventData
511def getTomorrowsEvents():
512 """Grabs each event that occurs tomorrow"""
513 tomorrowDate = date.today() + timedelta(days=1)
514 events = list(Event.select().where(Event.startDate==tomorrowDate))
515 return events
517def addEventView(viewer,event):
518 """This checks if the current user already viewed the event. If not, insert a recored to EventView table"""
519 if not viewer.isCeltsAdmin:
520 EventView.get_or_create(user = viewer, event = event)
522def getEventRsvpCountsForTerm(term):
523 """
524 Get all of the RSVPs for the events that exist in the term.
525 Returns a dictionary with the event id as the key and the amount of
526 current RSVPs to that event as the pair.
527 """
528 amount = (Event.select(Event, fn.COUNT(EventRsvp.event_id).alias('count'))
529 .join(EventRsvp, JOIN.LEFT_OUTER)
530 .where(Event.term == term, Event.deletionDate == None)
531 .group_by(Event.id))
533 amountAsDict = {event.id: event.count for event in amount}
535 return amountAsDict
537def getEventRsvpCount(eventId):
538 """
539 Returns the number of RSVP'd participants for a given eventId.
540 """
541 return len(EventRsvp.select().where(EventRsvp.event_id == eventId))
543def getCountdownToEvent(event, *, currentDatetime=None):
544 """
545 Given an event, this function returns a string that conveys the amount of time left
546 until the start of the event.
548 Note about dates:
549 Natural language is unintuitive. There are two major rules that govern how we discuss dates.
550 - If an event happens tomorrow but less than 24 hours away from us we still say that it happens
551 tomorrow with no mention of the hour.
552 - If an event happens tomorrow but more than 24 hours away from us, we'll count the number of days
553 and hours in actual time.
555 E.g. if the current time of day is greater than the event start's time of day, we give a number of days
556 relative to this morning and exclude all hours and minutes
558 On the other hand, if the current time of day is less or equal to the event's start of day we can produce
559 the real difference in days and hours without the aforementioned simplifying language.
560 """
562 if currentDatetime is None:
563 currentDatetime = datetime.now().replace(second=0, microsecond=0)
564 currentMorning = currentDatetime.replace(hour=0, minute=0)
566 eventStart = datetime.combine(event.startDate, event.timeStart)
567 eventEnd = datetime.combine(event.endDate, event.timeEnd)
569 if eventEnd < currentDatetime:
570 return "Already passed"
571 elif eventStart <= currentDatetime <= eventEnd:
572 return "Happening now"
574 timeUntilEvent = relativedelta(eventStart, currentDatetime)
575 calendarDelta = relativedelta(eventStart, currentMorning)
576 calendarYearsUntilEvent = calendarDelta.years
577 calendarMonthsUntilEvent = calendarDelta.months
578 calendarDaysUntilEvent = calendarDelta.days
580 yearString = f"{calendarYearsUntilEvent} year{'s' if calendarYearsUntilEvent > 1 else ''}"
581 monthString = f"{calendarMonthsUntilEvent} month{'s' if calendarMonthsUntilEvent > 1 else ''}"
582 dayString = f"{calendarDaysUntilEvent} day{'s' if calendarDaysUntilEvent > 1 else ''}"
583 hourString = f"{timeUntilEvent.hours} hour{'s' if timeUntilEvent.hours > 1 else ''}"
584 minuteString = f"{timeUntilEvent.minutes} minute{'s' if timeUntilEvent.minutes > 1 else ''}"
586 # Years until
587 if calendarYearsUntilEvent:
588 if calendarMonthsUntilEvent:
589 return f"{yearString} and {monthString}"
590 return f"{yearString}"
591 # Months until
592 if calendarMonthsUntilEvent:
593 if calendarDaysUntilEvent:
594 return f"{monthString} and {dayString}"
595 return f"{monthString}"
596 # Days until
597 if calendarDaysUntilEvent:
598 if eventStart.time() < currentDatetime.time():
599 if calendarDaysUntilEvent == 1:
600 return "Tomorrow"
601 return f"{dayString}"
602 if timeUntilEvent.hours:
603 return f"{dayString} and {hourString}"
604 return f"{dayString}"
605 # Hours until
606 if timeUntilEvent.hours:
607 if timeUntilEvent.minutes:
608 return f"{hourString} and {minuteString}"
609 return f"{hourString}"
610 # Minutes until
611 elif timeUntilEvent.minutes > 1:
612 return f"{minuteString}"
613 # Seconds until
614 return "<1 minute"
616def copyRsvpToNewEvent(priorEvent, newEvent):
617 """
618 Copies rvsps from priorEvent to newEvent
619 """
620 rsvpInfo = list(EventRsvp.select().where(EventRsvp.event == priorEvent['id']).execute())
622 for student in rsvpInfo:
623 newRsvp = EventRsvp(
624 user = student.user,
625 event = newEvent,
626 rsvpWaitlist = student.rsvpWaitlist
627 )
628 newRsvp.save()
629 numRsvps = len(rsvpInfo)
630 if numRsvps:
631 createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}")