Coverage for app/logic/events.py: 93%
288 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-07-11 17:51 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2024-07-11 17:51 +0000
1from flask import url_for
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.delete_instance(recursive = True, delete_nullable = True)
70def deleteEventAndAllFollowing(eventId):
71 """
72 Deletes a recurring event and all the recurring events after it.
73 """
74 event = Event.get_or_none(Event.id == eventId)
75 if event:
76 if event.recurringId:
77 recurringId = event.recurringId
78 recurringSeries = list(Event.select().where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate)))
79 for seriesEvent in recurringSeries:
80 seriesEvent.delete_instance(recursive = True)
82def deleteAllRecurringEvents(eventId):
83 """
84 Deletes all recurring events.
85 """
86 event = Event.get_or_none(Event.id == eventId)
87 if event:
88 if event.recurringId:
89 recurringId = event.recurringId
90 allRecurringEvents = list(Event.select().where(Event.recurringId == recurringId))
91 for aRecurringEvent in allRecurringEvents:
92 aRecurringEvent.delete_instance(recursive = True)
95def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False):
96 """
97 Tries to save an event to the database:
98 Checks that the event data is valid and if it is it continus to saves the new
99 event to the database and adds files if there are any.
100 If it is not valid it will return a validation error.
102 Returns:
103 Created events and an error message.
104 """
106 # Manually set the value of RSVP Limit if it is and empty string since it is
107 # automatically changed from "" to 0
108 if eventData["rsvpLimit"] == "":
109 eventData["rsvpLimit"] = None
110 newEventData = preprocessEventData(eventData)
112 isValid, validationErrorMessage = validateNewEventData(newEventData)
114 if not isValid:
115 return False, validationErrorMessage
117 try:
118 events = saveEventToDb(newEventData, renewedEvent)
119 if attachmentFiles:
120 for event in events:
121 addFile = FileHandler(attachmentFiles, eventId=event.id)
122 addFile.saveFiles(saveOriginalFile=events[0])
123 return events, ""
124 except Exception as e:
125 print(f'Failed attemptSaveEvent() with Exception: {e}')
126 return False, e
128def saveEventToDb(newEventData, renewedEvent = False):
130 if not newEventData.get('valid', False) and not renewedEvent:
131 raise Exception("Unvalidated data passed to saveEventToDb")
134 isNewEvent = ('id' not in newEventData)
137 eventsToCreate = []
138 recurringSeriesId = None
139 if (isNewEvent and newEventData['isRecurring']) and not renewedEvent:
140 eventsToCreate = calculateRecurringEventFrequency(newEventData)
141 recurringSeriesId = calculateNewrecurringId()
142 else:
143 eventsToCreate.append({'name': f"{newEventData['name']}",
144 'date':newEventData['startDate'],
145 "week":1})
146 if renewedEvent:
147 recurringSeriesId = newEventData.get('recurringId')
148 eventRecords = []
149 for eventInstance in eventsToCreate:
150 with mainDB.atomic():
152 eventData = {
153 "term": newEventData['term'],
154 "name": eventInstance['name'],
155 "description": newEventData['description'],
156 "timeStart": newEventData['timeStart'],
157 "timeEnd": newEventData['timeEnd'],
158 "location": newEventData['location'],
159 "isFoodProvided" : newEventData['isFoodProvided'],
160 "isTraining": newEventData['isTraining'],
161 "isRsvpRequired": newEventData['isRsvpRequired'],
162 "isService": newEventData['isService'],
163 "startDate": eventInstance['date'],
164 "rsvpLimit": newEventData['rsvpLimit'],
165 "endDate": eventInstance['date'],
166 "contactEmail": newEventData['contactEmail'],
167 "contactName": newEventData['contactName']
168 }
170 # The three fields below are only relevant during event creation so we only set/change them when
171 # it is a new event.
172 if isNewEvent:
173 eventData['program'] = newEventData['program']
174 eventData['recurringId'] = recurringSeriesId
175 eventData["isAllVolunteerTraining"] = newEventData['isAllVolunteerTraining']
176 eventRecord = Event.create(**eventData)
177 else:
178 eventRecord = Event.get_by_id(newEventData['id'])
179 Event.update(**eventData).where(Event.id == eventRecord).execute()
181 if 'certRequirement' in newEventData and newEventData['certRequirement'] != "":
182 updateCertRequirementForEvent(eventRecord, newEventData['certRequirement'])
184 eventRecords.append(eventRecord)
185 return eventRecords
187def getStudentLedEvents(term):
188 studentLedEvents = list(Event.select(Event, Program)
189 .join(Program)
190 .where(Program.isStudentLed,
191 Event.term == term)
192 .order_by(Event.startDate, Event.timeStart)
193 .execute())
195 programs = {}
197 for event in studentLedEvents:
198 programs.setdefault(event.program, []).append(event)
200 return programs
202def getUpcomingStudentLedCount(term, currentTime):
203 """
204 Return a count of all upcoming events for each student led program.
205 """
207 upcomingCount = (Program.select(Program.id, fn.COUNT(Event.id).alias("eventCount"))
208 .join(Event, on=(Program.id == Event.program_id))
209 .where(Program.isStudentLed,
210 Event.term == term,
211 (Event.endDate > currentTime) | ((Event.endDate == currentTime) & (Event.timeEnd >= currentTime)),
212 Event.isCanceled == False)
213 .group_by(Program.id))
215 programCountDict = {}
217 for programCount in upcomingCount:
218 programCountDict[programCount.id] = programCount.eventCount
219 return programCountDict
221def getTrainingEvents(term, user):
222 """
223 The allTrainingsEvent query is designed to select and count eventId's after grouping them
224 together by id's of similiar value. The query will then return the event that is associated
225 with the most programs (highest count) by doing this we can ensure that the event being
226 returned is the All Trainings Event.
227 term: expected to be the ID of a term
228 user: expected to be the current user
229 return: a list of all trainings the user can view
230 """
231 trainingQuery = (Event.select(Event).distinct()
232 .join(Program, JOIN.LEFT_OUTER)
233 .where(Event.isTraining == True,
234 Event.term == term)
235 .order_by(Event.isAllVolunteerTraining.desc(), Event.startDate, Event.timeStart))
237 hideBonner = (not user.isAdmin) and not (user.isStudent and user.isBonnerScholar)
238 if hideBonner:
239 trainingQuery = trainingQuery.where(Program.isBonnerScholars == False)
241 return list(trainingQuery.execute())
243def getBonnerEvents(term):
244 bonnerScholarsEvents = list(Event.select(Event, Program.id.alias("program_id"))
245 .join(Program)
246 .where(Program.isBonnerScholars,
247 Event.term == term)
248 .order_by(Event.startDate, Event.timeStart)
249 .execute())
250 return bonnerScholarsEvents
252def getOtherEvents(term):
253 """
254 Get the list of the events not caught by other functions to be displayed in
255 the Other Events section of the Events List page.
256 :return: A list of Other Event objects
257 """
258 # Gets all events that are not associated with a program and are not trainings
259 # Gets all events that have a program but don't fit anywhere
261 otherEvents = list(Event.select(Event, Program)
262 .join(Program, JOIN.LEFT_OUTER)
263 .where(Event.term == term,
264 Event.isTraining == False,
265 Event.isAllVolunteerTraining == False,
266 ((Program.isOtherCeltsSponsored) |
267 ((Program.isStudentLed == False) &
268 (Program.isBonnerScholars == False))))
269 .order_by(Event.startDate, Event.timeStart, Event.id)
270 .execute())
272 return otherEvents
274def getUpcomingEventsForUser(user, asOf=datetime.now(), program=None):
275 """
276 Get the list of upcoming events that the user is interested in as long
277 as they are not banned from the program that the event is a part of.
278 :param user: a username or User object
279 :param asOf: The date to use when determining future and past events.
280 Used in testing, defaults to the current timestamp.
281 :return: A list of Event objects
282 """
284 events = (Event.select().distinct()
285 .join(ProgramBan, JOIN.LEFT_OUTER, on=((ProgramBan.program == Event.program) & (ProgramBan.user == user)))
286 .join(Interest, JOIN.LEFT_OUTER, on=(Event.program == Interest.program))
287 .join(EventRsvp, JOIN.LEFT_OUTER, on=(Event.id == EventRsvp.event))
288 .where(Event.startDate >= asOf,
289 (Interest.user == user) | (EventRsvp.user == user),
290 ProgramBan.user.is_null(True) | (ProgramBan.endDate < asOf)))
292 if program:
293 events = events.where(Event.program == program)
295 events = events.order_by(Event.startDate, Event.name)
297 events_list = []
298 shown_recurring_event_list = []
300 # removes all recurring events except for the next upcoming one
301 for event in events:
302 if event.recurringId:
303 if not event.isCanceled:
304 if event.recurringId not in shown_recurring_event_list:
305 events_list.append(event)
306 shown_recurring_event_list.append(event.recurringId)
307 else:
308 if not event.isCanceled:
309 events_list.append(event)
311 return events_list
313def getParticipatedEventsForUser(user):
314 """
315 Get all the events a user has participated in.
316 :param user: a username or User object
317 :param asOf: The date to use when determining future and past events.
318 Used in testing, defaults to the current timestamp.
319 :return: A list of Event objects
320 """
322 participatedEvents = (Event.select(Event, Program.programName)
323 .join(Program, JOIN.LEFT_OUTER).switch()
324 .join(EventParticipant)
325 .where(EventParticipant.user == user,
326 Event.isAllVolunteerTraining == False)
327 .order_by(Event.startDate, Event.name))
329 allVolunteer = (Event.select(Event, "")
330 .join(EventParticipant)
331 .where(Event.isAllVolunteerTraining == True,
332 EventParticipant.user == user))
333 union = participatedEvents.union_all(allVolunteer)
334 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())
336 return unionParticipationWithVolunteer
338def validateNewEventData(data):
339 """
340 Confirm that the provided data is valid for an event.
342 Assumes the event data has been processed with `preprocessEventData`. NOT raw form data
344 Returns 3 values: (boolean success, the validation error message, the data object)
345 """
347 if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isService'], data['isRecurring']]:
348 return (False, "Raw form data passed to validate method. Preprocess first.")
350 if data['isRecurring'] and data['endDate'] < data['startDate']:
351 return (False, "Event start date is after event end date.")
353 if data['timeEnd'] <= data['timeStart']:
354 return (False, "Event end time must be after start time.")
356 # Validation if we are inserting a new event
357 if 'id' not in data:
359 sameEventList = list((Event.select().where((Event.name == data['name']) &
360 (Event.location == data['location']) &
361 (Event.startDate == data['startDate']) &
362 (Event.timeStart == data['timeStart'])).execute()))
364 sameEventListCopy = sameEventList.copy()
366 for event in sameEventListCopy:
367 if event.isCanceled or event.recurringId:
368 sameEventList.remove(event)
370 try:
371 Term.get_by_id(data['term'])
372 except DoesNotExist as e:
373 return (False, f"Not a valid term: {data['term']}")
374 if sameEventList:
375 return (False, "This event already exists")
377 data['valid'] = True
378 return (True, "All inputs are valid.")
380def calculateNewrecurringId():
381 """
382 Gets the highest recurring Id so that a new recurring Id can be assigned
383 """
384 recurringId = Event.select(fn.MAX(Event.recurringId)).scalar()
385 if recurringId:
386 return recurringId + 1
387 else:
388 return 1
390def getPreviousRecurringEventData(recurringId):
391 """
392 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
393 """
394 previousEventVolunteers = (User.select(User).distinct()
395 .join(EventParticipant)
396 .join(Event)
397 .where(Event.recurringId==recurringId))
398 return previousEventVolunteers
400def calculateRecurringEventFrequency(event):
401 """
402 Calculate the events to create based on a recurring event start and end date. Takes a
403 dictionary of event data.
405 Assumes that the data has been processed with `preprocessEventData`. NOT raw form data.
407 Return a list of events to create from the event data.
408 """
409 if not isinstance(event['endDate'], date) or not isinstance(event['startDate'], date):
410 raise Exception("startDate and endDate must be datetime.date objects.")
412 if event['endDate'] == event['startDate']:
413 raise Exception("This event is not a recurring event")
414 return [ {'name': f"{event['name']} Week {counter+1}",
415 'date': event['startDate'] + timedelta(days=7*counter),
416 "week": counter+1}
417 for counter in range(0, ((event['endDate']-event['startDate']).days//7)+1)]
419def preprocessEventData(eventData):
420 """
421 Ensures that the event data dictionary is consistent before it reaches the template or event logic.
423 - dates should exist and be date objects if there is a value
424 - checkboxes should be True or False
425 - if term is given, convert it to a model object
426 - times should exist be strings in 24 hour format example: 14:40
427 - Look up matching certification requirement if necessary
428 """
429 ## Process checkboxes
430 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isRecurring', 'isAllVolunteerTraining']
432 for checkBox in eventCheckBoxes:
433 if checkBox not in eventData:
434 eventData[checkBox] = False
435 else:
436 eventData[checkBox] = bool(eventData[checkBox])
438 ## Process dates
439 eventDates = ['startDate', 'endDate']
440 for eventDate in eventDates:
441 if eventDate not in eventData:
442 eventData[eventDate] = ''
443 elif type(eventData[eventDate]) is str and eventData[eventDate]:
444 eventData[eventDate] = parser.parse(eventData[eventDate])
445 elif not isinstance(eventData[eventDate], date):
446 eventData[eventDate] = ''
448 # If we aren't recurring, all of our events are single-day
449 if not eventData['isRecurring']:
450 eventData['endDate'] = eventData['startDate']
452 # Process terms
453 if 'term' in eventData:
454 try:
455 eventData['term'] = Term.get_by_id(eventData['term'])
456 except DoesNotExist:
457 eventData['term'] = ''
459 # Process requirement
460 if 'certRequirement' in eventData:
461 try:
462 eventData['certRequirement'] = CertificationRequirement.get_by_id(eventData['certRequirement'])
463 except DoesNotExist:
464 eventData['certRequirement'] = ''
465 elif 'id' in eventData:
466 # look up requirement
467 match = RequirementMatch.get_or_none(event=eventData['id'])
468 if match:
469 eventData['certRequirement'] = match.requirement
470 if 'timeStart' in eventData:
471 eventData['timeStart'] = format24HourTime(eventData['timeStart'])
473 if 'timeEnd' in eventData:
474 eventData['timeEnd'] = format24HourTime(eventData['timeEnd'])
476 return eventData
478def getTomorrowsEvents():
479 """Grabs each event that occurs tomorrow"""
480 tomorrowDate = date.today() + timedelta(days=1)
481 events = list(Event.select().where(Event.startDate==tomorrowDate))
482 return events
484def addEventView(viewer,event):
485 """This checks if the current user already viewed the event. If not, insert a recored to EventView table"""
486 if not viewer.isCeltsAdmin:
487 EventView.get_or_create(user = viewer, event = event)
489def getEventRsvpCountsForTerm(term):
490 """
491 Get all of the RSVPs for the events that exist in the term.
492 Returns a dictionary with the event id as the key and the amount of
493 current RSVPs to that event as the pair.
494 """
495 amount = (Event.select(Event, fn.COUNT(EventRsvp.event_id).alias('count'))
496 .join(EventRsvp, JOIN.LEFT_OUTER)
497 .where(Event.term == term)
498 .group_by(Event.id))
500 amountAsDict = {event.id: event.count for event in amount}
502 return amountAsDict
504def getEventRsvpCount(eventId):
505 """
506 Returns the number of RSVP'd participants for a given eventId.
507 """
508 return len(EventRsvp.select().where(EventRsvp.event_id == eventId))
510def getCountdownToEvent(event, *, currentDatetime=None):
511 """
512 Given an event, this function returns a string that conveys the amount of time left
513 until the start of the event.
515 Note about dates:
516 Natural language is unintuitive. There are two major rules that govern how we discuss dates.
517 - If an event happens tomorrow but less than 24 hours away from us we still say that it happens
518 tomorrow with no mention of the hour.
519 - If an event happens tomorrow but more than 24 hours away from us, we'll count the number of days
520 and hours in actual time.
522 E.g. if the current time of day is greater than the event start's time of day, we give a number of days
523 relative to this morning and exclude all hours and minutes
525 On the other hand, if the current time of day is less or equal to the event's start of day we can produce
526 the real difference in days and hours without the aforementioned simplifying language.
527 """
529 if currentDatetime is None:
530 currentDatetime = datetime.now().replace(second=0, microsecond=0)
531 currentMorning = currentDatetime.replace(hour=0, minute=0)
533 eventStart = datetime.combine(event.startDate, event.timeStart)
534 eventEnd = datetime.combine(event.endDate, event.timeEnd)
536 if eventEnd < currentDatetime:
537 return "Already passed"
538 elif eventStart <= currentDatetime <= eventEnd:
539 return "Happening now"
541 timeUntilEvent = relativedelta(eventStart, currentDatetime)
542 calendarDelta = relativedelta(eventStart, currentMorning)
543 calendarYearsUntilEvent = calendarDelta.years
544 calendarMonthsUntilEvent = calendarDelta.months
545 calendarDaysUntilEvent = calendarDelta.days
547 yearString = f"{calendarYearsUntilEvent} year{'s' if calendarYearsUntilEvent > 1 else ''}"
548 monthString = f"{calendarMonthsUntilEvent} month{'s' if calendarMonthsUntilEvent > 1 else ''}"
549 dayString = f"{calendarDaysUntilEvent} day{'s' if calendarDaysUntilEvent > 1 else ''}"
550 hourString = f"{timeUntilEvent.hours} hour{'s' if timeUntilEvent.hours > 1 else ''}"
551 minuteString = f"{timeUntilEvent.minutes} minute{'s' if timeUntilEvent.minutes > 1 else ''}"
553 # Years until
554 if calendarYearsUntilEvent:
555 if calendarMonthsUntilEvent:
556 return f"{yearString} and {monthString}"
557 return f"{yearString}"
558 # Months until
559 if calendarMonthsUntilEvent:
560 if calendarDaysUntilEvent:
561 return f"{monthString} and {dayString}"
562 return f"{monthString}"
563 # Days until
564 if calendarDaysUntilEvent:
565 if eventStart.time() < currentDatetime.time():
566 if calendarDaysUntilEvent == 1:
567 return "Tomorrow"
568 return f"{dayString}"
569 if timeUntilEvent.hours:
570 return f"{dayString} and {hourString}"
571 return f"{dayString}"
572 # Hours until
573 if timeUntilEvent.hours:
574 if timeUntilEvent.minutes:
575 return f"{hourString} and {minuteString}"
576 return f"{hourString}"
577 # Minutes until
578 elif timeUntilEvent.minutes > 1:
579 return f"{minuteString}"
580 # Seconds until
581 return "<1 minute"
583def copyRsvpToNewEvent(priorEvent, newEvent):
584 """
585 Copies rvsps from priorEvent to newEvent
586 """
587 rsvpInfo = list(EventRsvp.select().where(EventRsvp.event == priorEvent['id']).execute())
589 for student in rsvpInfo:
590 newRsvp = EventRsvp(
591 user = student.user,
592 event = newEvent,
593 rsvpWaitlist = student.rsvpWaitlist
594 )
595 newRsvp.save()
596 numRsvps = len(rsvpInfo)
597 if numRsvps:
598 createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}")