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