Coverage for app/logic/events.py: 93%

333 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-22 19:51 +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 

20from app.models.eventCohort import EventCohort 

21 

22from app.logic.bonner import rsvpForBonnerCohort, addBonnerCohortToRsvpLog 

23from app.logic.createLogs import createActivityLog, createRsvpLog 

24from app.logic.utils import format24HourTime 

25from app.logic.fileHandler import FileHandler 

26from app.logic.certification import updateCertRequirementForEvent 

27 

28def cancelEvent(eventId): 

29 """ 

30 Cancels an event. 

31 """ 

32 event = Event.get_or_none(Event.id == eventId) 

33 if event: 

34 event.isCanceled = True 

35 event.save() 

36 

37 program = event.program 

38 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')}.") 

39 

40#NEEDS FIXING: process not working properly for repeating events when two events are deleted consecutively 

41def deleteEvent(eventId): 

42 """ 

43 Deletes an event, if it is a repeating event, rename all following events 

44 to make sure there is no gap in weeks. 

45  

46 """ 

47 event = Event.get_or_none(Event.id == eventId) 

48 

49 if event: 

50 if event.isRepeating: 

51 seriesId = event.seriesId 

52 repeatingEvents = list(Event.select().where(Event.seriesId==seriesId).order_by(Event.id)) # orders for tests 

53 eventDeleted = False 

54 # once the deleted event is detected, change all other names to the previous event's name 

55 for repeatingEvent in repeatingEvents: 

56 if eventDeleted: 

57 Event.update({Event.name:newEventName}).where(Event.id==repeatingEvent.id).execute() 

58 newEventName = repeatingEvent.name 

59 

60 if repeatingEvent == event: 

61 newEventName = repeatingEvent.name 

62 eventDeleted = True 

63 

64 program = event.program 

65 

66 if program: 

67 createActivityLog(f"Deleted \"{event.name}\" for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") 

68 else: 

69 createActivityLog(f"Deleted a non-program event, \"{event.name}\", which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") 

70 

71 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where(Event.id == event.id).execute() 

72 

73def deleteEventAndAllFollowing(eventId): 

74 """ 

75 Deletes an event in the series and all events after it 

76  

77 """ 

78 event = Event.get_or_none(Event.id == eventId) 

79 if event: 

80 if event.seriesId: 

81 seriesId = event.seriesId 

82 eventSeries = list(Event.select(Event.id).where((Event.seriesId == seriesId) & (Event.startDate >= event.startDate))) 

83 deletedEventList = [event.id for event in eventSeries] 

84 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where((Event.seriesId == seriesId) & (Event.startDate >= event.startDate)).execute() 

85 return deletedEventList 

86 

87def deleteAllEventsInSeries(eventId): 

88 """ 

89 Deletes all events in a series by getting the first event in the series and calling deleteEventAndAllFollowing(). 

90  

91 """ 

92 event = Event.get_or_none(Event.id == eventId) 

93 if event: 

94 if event.seriesId: 

95 seriesId = event.seriesId 

96 allSeriesEvents = list(Event.select(Event.id).where(Event.seriesId == seriesId).order_by(Event.startDate)) 

97 eventId = allSeriesEvents[0].id 

98 return deleteEventAndAllFollowing(eventId) 

99 else: 

100 raise ValueError(f"Event with id {event.id} does not belong to a series (seriesId is None).") 

101 

102def attemptSaveMultipleOfferings(eventData, attachmentFiles = None): 

103 """ 

104 Tries to save an event with multiple offerings to the database: 

105 Creates separate event data inheriting from the original eventData 

106 with the specifics of each offering. 

107 Calls attemptSaveEvent on each of the newly created datum 

108 If any data is not valid it will return a validation error. 

109 

110 Returns: 

111 allSavesWereSuccessful : bool | Whether or not all offering saves were successful 

112 savedOfferings : List[event] | A list of event objects holding all offerings that were saved. If allSavesWereSuccessful is False then this list will be empty. 

113 failedSavedOfferings : List[(int, str), ...] | Tuples containing the indicies of failed saved offerings and the associated validation error message.  

114 """ 

115 savedOfferings = [] 

116 failedSavedOfferings = [] 

117 allSavesWereSuccessful = True 

118 

119 seriesId = calculateNewSeriesId() 

120 

121 # Create separate event data inheriting from the original eventData 

122 seriesData = eventData.get('seriesData') 

123 isRepeating = bool(eventData.get('isRepeating')) 

124 with mainDB.atomic() as transaction: 

125 for index, event in enumerate(seriesData): 

126 eventInfo = eventData.copy() 

127 eventInfo.update({ 

128 'name': event['eventName'], 

129 'startDate': event['eventDate'], 

130 'timeStart': event['startTime'], 

131 'timeEnd': event['endTime'], 

132 'seriesId': seriesId, 

133 'isRepeating': bool(isRepeating) 

134 }) 

135 # Try to save each offering 

136 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) 

137 if validationErrorMessage: 

138 failedSavedOfferings.append((index, validationErrorMessage)) 

139 allSavesWereSuccessful = False 

140 else: 

141 savedEvent = savedEvents[0] 

142 savedOfferings.append(savedEvent) 

143 if not allSavesWereSuccessful: 

144 savedOfferings = [] 

145 transaction.rollback() 

146 

147 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings 

148 

149 

150def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False): 

151 """ 

152 Tries to save an event to the database: 

153 Checks that the event data is valid and if it is, it continues to save the new 

154 event to the database and adds files if there are any. 

155 If it is not valid it will return a validation error. 

156 

157 Returns: 

158 The saved event, created events and an error message if an error occurred. 

159 """ 

160 

161 # Manually set the value of RSVP Limit if it is and empty string since it is 

162 # automatically changed from "" to 0 

163 if eventData["rsvpLimit"] == "": 

164 eventData["rsvpLimit"] = None 

165 

166 newEventData = preprocessEventData(eventData) 

167 

168 isValid, validationErrorMessage = validateNewEventData(newEventData) 

169 if not isValid: 

170 return [], validationErrorMessage 

171 

172 events = saveEventToDb(newEventData, renewedEvent) 

173 if attachmentFiles: 

174 for event in events: 

175 addFile = FileHandler(attachmentFiles, eventId=event.id) 

176 addFile.saveFiles(saveOriginalFile=events[0]) 

177 return events, "" 

178 

179 

180def saveEventToDb(newEventData, renewedEvent = False): 

181 

182 if not newEventData.get('valid', False) and not renewedEvent: 

183 raise Exception("Unvalidated data passed to saveEventToDb") 

184 

185 isNewEvent = ('id' not in newEventData) 

186 

187 eventRecords = [] 

188 with mainDB.atomic(): 

189 

190 eventData = { 

191 "term": newEventData['term'], 

192 "name": newEventData['name'], 

193 "description": newEventData['description'], 

194 "timeStart": newEventData['timeStart'], 

195 "timeEnd": newEventData['timeEnd'], 

196 "location": newEventData['location'], 

197 "isFoodProvided" : newEventData['isFoodProvided'], 

198 "isLaborOnly" : newEventData['isLaborOnly'], 

199 "isTraining": newEventData['isTraining'], 

200 "isEngagement": newEventData['isEngagement'], 

201 "isRsvpRequired": newEventData['isRsvpRequired'], 

202 "isService": newEventData['isService'], 

203 "startDate": newEventData['startDate'], 

204 "rsvpLimit": newEventData['rsvpLimit'], 

205 "contactEmail": newEventData['contactEmail'], 

206 "contactName": newEventData['contactName'], 

207 } 

208 

209 # The three fields below are only relevant during event creation so we only set/change them when  

210 # it is a new event.  

211 if isNewEvent: 

212 eventData['program'] = newEventData['program'] 

213 eventData['seriesId'] = newEventData.get('seriesId') 

214 eventData['isRepeating'] = bool(newEventData.get('isRepeating')) 

215 eventData["isAllVolunteerTraining"] = newEventData['isAllVolunteerTraining'] 

216 eventRecord = Event.create(**eventData) 

217 else: 

218 eventRecord = Event.get_by_id(newEventData['id']) 

219 Event.update(**eventData).where(Event.id == eventRecord).execute() 

220 

221 if 'certRequirement' in newEventData and newEventData['certRequirement'] != "": 

222 updateCertRequirementForEvent(eventRecord, newEventData['certRequirement']) 

223 

224 eventRecords.append(eventRecord) 

225 return eventRecords 

226 

227def getStudentLedEvents(term): 

228 studentLedEvents = list(Event.select(Event, Program) 

229 .join(Program) 

230 .where(Program.isStudentLed, 

231 Event.term == term, Event.deletionDate == None) 

232 .order_by(Event.startDate, Event.timeStart) 

233 .execute()) 

234 

235 programs = {} 

236 

237 for event in studentLedEvents: 

238 programs.setdefault(event.program, []).append(event) 

239 

240 return programs 

241 

242def getEngagementEvents(term): 

243 engagementEvents = list(Event.select(Event, Program) 

244 .join(Program) 

245 .where(Event.isEngagement, 

246 Event.term == term, Event.deletionDate == None) 

247 .order_by(Event.startDate, Event.timeStart) 

248 .execute()) 

249 return engagementEvents 

250 

251def getUpcomingStudentLedCount(term, currentTime): 

252 """ 

253 Return a count of all upcoming events for each student led program. 

254 """ 

255 

256 upcomingCount = (Program.select(Program.id, fn.COUNT(Event.id).alias("eventCount")) 

257 .join(Event, on=(Program.id == Event.program_id)) 

258 .where(Program.isStudentLed, 

259 Event.term == term, Event.deletionDate == None, 

260 (Event.startDate > currentTime) | ((Event.startDate == currentTime) & (Event.timeEnd >= currentTime)), 

261 Event.isCanceled == False) 

262 .group_by(Program.id)) 

263 

264 programCountDict = {} 

265 

266 for programCount in upcomingCount: 

267 programCountDict[programCount.id] = programCount.eventCount 

268 return programCountDict 

269 

270def getTrainingEvents(term, user): 

271 """ 

272 The allTrainingsEvent query is designed to select and count eventId's after grouping them 

273 together by id's of similiar value. The query will then return the event that is associated 

274 with the most programs (highest count) by doing this we can ensure that the event being 

275 returned is the All Trainings Event. 

276 term: expected to be the ID of a term 

277 user: expected to be the current user 

278 return: a list of all trainings the user can view 

279 """ 

280 trainingQuery = (Event.select(Event).distinct() 

281 .join(Program, JOIN.LEFT_OUTER) 

282 .where(Event.isTraining == True, 

283 Event.term == term, Event.deletionDate == None) 

284 .order_by(Event.isAllVolunteerTraining.desc(), Event.startDate, Event.timeStart)) 

285 

286 hideBonner = (not user.isAdmin) and not (user.isStudent and user.isBonnerScholar) 

287 if hideBonner: 

288 trainingQuery = trainingQuery.where(Program.isBonnerScholars == False) 

289 

290 return list(trainingQuery.execute()) 

291 

292def getBonnerEvents(term): 

293 bonnerScholarsEvents = list(Event.select(Event, Program.id.alias("program_id")) 

294 .join(Program) 

295 .where(Program.isBonnerScholars, 

296 Event.term == term, Event.deletionDate == None) 

297 .order_by(Event.startDate, Event.timeStart) 

298 .execute()) 

299 return bonnerScholarsEvents 

300 

301def getOtherEvents(term): 

302 """ 

303  

304 Get the list of the events not caught by other functions to be displayed in 

305 the Other Events section of the Events List page. 

306 :return: A list of Other Event objects 

307 """ 

308 # Gets all events that are not associated with a program and are not trainings 

309 # Gets all events that have a program but don't fit anywhere 

310 

311 otherEvents = list(Event.select(Event, Program) 

312 .join(Program, JOIN.LEFT_OUTER) 

313 .where(Event.term == term, Event.deletionDate == None, 

314 Event.isTraining == False, 

315 Event.isAllVolunteerTraining == False, 

316 ((Program.isOtherCeltsSponsored) | 

317 ((Program.isStudentLed == False) & 

318 (Program.isBonnerScholars == False)))) 

319 .order_by(Event.startDate, Event.timeStart, Event.id) 

320 .execute()) 

321 

322 return otherEvents 

323 

324def getUpcomingEventsForUser(user, asOf=datetime.now(), program=None): 

325 """ 

326 Get the list of upcoming events that the user is interested in as long 

327 as they are not banned from the program that the event is a part of. 

328 :param user: a username or User object 

329 :param asOf: The date to use when determining future and past events. 

330 Used in testing, defaults to the current timestamp. 

331 :return: A list of Event objects 

332 """ 

333 

334 events = (Event.select().distinct() 

335 .join(ProgramBan, JOIN.LEFT_OUTER, on=((ProgramBan.program == Event.program) & (ProgramBan.user == user))) 

336 .join(Interest, JOIN.LEFT_OUTER, on=(Event.program == Interest.program)) 

337 .join(EventRsvp, JOIN.LEFT_OUTER, on=(Event.id == EventRsvp.event)) 

338 .where(Event.deletionDate == None, Event.startDate >= asOf, 

339 (Interest.user == user) | (EventRsvp.user == user), 

340 ProgramBan.user.is_null(True) | (ProgramBan.endDate < asOf))) 

341 

342 if program: 

343 events = events.where(Event.program == program) 

344 

345 events = events.order_by(Event.startDate, Event.timeStart) 

346 

347 eventsList = [] 

348 seriesEventsList = [] 

349 

350 # removes all events in series except for the next upcoming one 

351 for event in events: 

352 if event.seriesId: 

353 if not event.isCanceled: 

354 if event.seriesId not in seriesEventsList: 

355 eventsList.append(event) 

356 seriesEventsList.append(event.seriesId) 

357 else: 

358 if not event.isCanceled: 

359 eventsList.append(event) 

360 

361 return eventsList 

362 

363def getParticipatedEventsForUser(user): 

364 """ 

365 Get all the events a user has participated in. 

366 :param user: a username or User object 

367 :param asOf: The date to use when determining future and past events. 

368 Used in testing, defaults to the current timestamp. 

369 :return: A list of Event objects 

370 """ 

371 

372 participatedEvents = (Event.select(Event, Program.programName) 

373 .join(Program, JOIN.LEFT_OUTER).switch() 

374 .join(EventParticipant) 

375 .where(EventParticipant.user == user, 

376 Event.isAllVolunteerTraining == False, Event.deletionDate == None) 

377 .order_by(Event.startDate, Event.name)) 

378 

379 allVolunteer = (Event.select(Event, "") 

380 .join(EventParticipant) 

381 .where(Event.isAllVolunteerTraining == True, 

382 EventParticipant.user == user)) 

383 union = participatedEvents.union_all(allVolunteer) 

384 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()) 

385 

386 return unionParticipationWithVolunteer 

387 

388def validateNewEventData(data): 

389 """ 

390 Confirm that the provided data is valid for an event. 

391 

392 Assumes the event data has been processed with `preprocessEventData`. NOT raw form data 

393 

394 Returns 3 values: (boolean success, the validation error message, the data object) 

395 """ 

396 

397 if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isEngagement'], data['isService'], data['isRepeating'], data['isLaborOnly']]: 

398 return (False, "Raw form data passed to validate method. Preprocess first.") 

399 

400 if data['timeEnd'] <= data['timeStart']: 

401 return (False, "Event end time must be after start time.") 

402 

403 # Validation if we are inserting a new event 

404 if 'id' not in data: 

405 

406 sameEventList = list((Event.select().where((Event.name == data['name']) & 

407 (Event.location == data['location']) & 

408 (Event.startDate == data['startDate']) & 

409 (Event.timeStart == data['timeStart'])).execute())) 

410 

411 sameEventListCopy = sameEventList.copy() 

412 

413 for event in sameEventListCopy: 

414 if event.isCanceled or (event.seriesId and event.isRepeating): 

415 sameEventList.remove(event) 

416 

417 try: 

418 Term.get_by_id(data['term']) 

419 except DoesNotExist as e: 

420 return (False, f"Not a valid term: {data['term']}") 

421 if sameEventList: 

422 return (False, "This event already exists") 

423 

424 data['valid'] = True 

425 return (True, "All inputs are valid.") 

426 

427def calculateNewSeriesId(): 

428 """ 

429 Gets the max series ID so that new seriesId can be assigned. 

430 """ 

431 maxSeriesId = Event.select(fn.MAX(Event.seriesId)).scalar() 

432 if maxSeriesId: 

433 return maxSeriesId + 1 

434 return 1 

435 

436def getPreviousSeriesEventData(seriesId): 

437 """ 

438 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. 

439  

440 """ 

441 previousEventVolunteers = (User.select(User).distinct() 

442 .join(EventParticipant) 

443 .join(Event) 

444 .where(Event.seriesId==seriesId)) 

445 return previousEventVolunteers 

446 

447def getRepeatingEventsData(eventData): 

448 """ 

449 Calculate the events to create based on a repeating event start and end date. Takes a 

450 dictionary of event data. 

451  

452 Assumes that the data has been processed with `preprocessEventData`. NOT raw form data. 

453 

454 Return a list of events to create from the event data. 

455 """ 

456 

457 return [ {'name': f"{eventData['name']} Week {counter+1}", 

458 'date': eventData['startDate'] + timedelta(days=7*counter), 

459 "week": counter+1} 

460 for counter in range(0, ((eventData['endDate']-eventData['startDate']).days//7)+1)] 

461 

462def preprocessEventData(eventData): 

463 """ 

464 Ensures that the event data dictionary is consistent before it reaches the template or event logic. 

465 

466 - dates should exist and be date objects if there is a value 

467 - checkboxes should be True or False 

468 - if term is given, convert it to a model object 

469 - times should exist be strings in 24 hour format example: 14:40 

470 - seriesData should be a JSON string 

471 - Look up matching certification requirement if necessary 

472 """ 

473 ## Process checkboxes 

474 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isEngagement', 'isRepeating', 'isAllVolunteerTraining', 'isLaborOnly'] 

475 

476 for checkBox in eventCheckBoxes: 

477 if checkBox not in eventData: 

478 eventData[checkBox] = False 

479 else: 

480 eventData[checkBox] = bool(eventData[checkBox]) 

481 

482 ## Process dates 

483 eventDates = ['startDate', 'endDate'] 

484 for eventDate in eventDates: 

485 if eventDate not in eventData: # There is no date given 

486 eventData[eventDate] = '' 

487 elif type(eventData[eventDate]) is str and eventData[eventDate]: # The date is a nonempty string  

488 eventData[eventDate] = parser.parse(eventData[eventDate]) 

489 elif not isinstance(eventData[eventDate], date): # The date is not a date object 

490 eventData[eventDate] = '' 

491 

492 # Process seriesData 

493 if 'seriesData' not in eventData: 

494 eventData['seriesData'] = json.dumps([]) 

495 

496 # Process terms 

497 if 'term' in eventData: 

498 try: 

499 eventData['term'] = Term.get_by_id(eventData['term']) 

500 except DoesNotExist: 

501 eventData['term'] = '' 

502 

503 # Process requirement 

504 if 'certRequirement' in eventData: 

505 try: 

506 eventData['certRequirement'] = CertificationRequirement.get_by_id(eventData['certRequirement']) 

507 except DoesNotExist: 

508 eventData['certRequirement'] = '' 

509 elif 'id' in eventData: 

510 # look up requirement 

511 match = RequirementMatch.get_or_none(event=eventData['id']) 

512 if match: 

513 eventData['certRequirement'] = match.requirement 

514 if 'timeStart' in eventData: 

515 eventData['timeStart'] = format24HourTime(eventData['timeStart']) 

516 

517 if 'timeEnd' in eventData: 

518 eventData['timeEnd'] = format24HourTime(eventData['timeEnd']) 

519 

520 return eventData 

521 

522def getTomorrowsEvents(): 

523 """Grabs each event that occurs tomorrow""" 

524 tomorrowDate = date.today() + timedelta(days=1) 

525 events = list(Event.select().where(Event.startDate==tomorrowDate)) 

526 return events 

527 

528def addEventView(viewer,event): 

529 """This checks if the current user already viewed the event. If not, insert a recored to EventView table""" 

530 if not viewer.isCeltsAdmin: 

531 EventView.get_or_create(user = viewer, event = event) 

532 

533def getEventRsvpCountsForTerm(term): 

534 """ 

535 Get all of the RSVPs for the events that exist in the term. 

536 Returns a dictionary with the event id as the key and the amount of 

537 current RSVPs to that event as the pair. 

538 """ 

539 amount = (Event.select(Event, fn.COUNT(EventRsvp.event_id).alias('count')) 

540 .join(EventRsvp, JOIN.LEFT_OUTER) 

541 .where(Event.term == term, Event.deletionDate == None) 

542 .group_by(Event.id)) 

543 

544 amountAsDict = {event.id: event.count for event in amount} 

545 

546 return amountAsDict 

547 

548def getEventRsvpCount(eventId): 

549 """ 

550 Returns the number of RSVP'd participants for a given eventId. 

551 """ 

552 return len(EventRsvp.select().where(EventRsvp.event_id == eventId)) 

553 

554def getCountdownToEvent(event, *, currentDatetime=None): 

555 """ 

556 Given an event, this function returns a string that conveys the amount of time left 

557 until the start of the event. 

558 

559 Note about dates: 

560 Natural language is unintuitive. There are two major rules that govern how we discuss dates. 

561 - If an event happens tomorrow but less than 24 hours away from us we still say that it happens  

562 tomorrow with no mention of the hour.  

563 - If an event happens tomorrow but more than 24 hours away from us, we'll count the number of days  

564 and hours in actual time. 

565 

566 E.g. if the current time of day is greater than the event start's time of day, we give a number of days  

567 relative to this morning and exclude all hours and minutes 

568 

569 On the other hand, if the current time of day is less or equal to the event's start of day we can produce  

570 the real difference in days and hours without the aforementioned simplifying language. 

571 """ 

572 

573 if currentDatetime is None: 

574 currentDatetime = datetime.now().replace(second=0, microsecond=0) 

575 currentMorning = currentDatetime.replace(hour=0, minute=0) 

576 

577 eventStart = datetime.combine(event.startDate, event.timeStart) 

578 eventEnd = datetime.combine(event.startDate, event.timeEnd) 

579 

580 if eventEnd < currentDatetime: 

581 return "Already passed" 

582 elif eventStart <= currentDatetime <= eventEnd: 

583 return "Happening now" 

584 

585 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

586 calendarDelta = relativedelta(eventStart, currentMorning) 

587 calendarYearsUntilEvent = calendarDelta.years 

588 calendarMonthsUntilEvent = calendarDelta.months 

589 calendarDaysUntilEvent = calendarDelta.days 

590 

591 yearString = f"{calendarYearsUntilEvent} year{'s' if calendarYearsUntilEvent > 1 else ''}" 

592 monthString = f"{calendarMonthsUntilEvent} month{'s' if calendarMonthsUntilEvent > 1 else ''}" 

593 dayString = f"{calendarDaysUntilEvent} day{'s' if calendarDaysUntilEvent > 1 else ''}" 

594 hourString = f"{timeUntilEvent.hours} hour{'s' if timeUntilEvent.hours > 1 else ''}" 

595 minuteString = f"{timeUntilEvent.minutes} minute{'s' if timeUntilEvent.minutes > 1 else ''}" 

596 

597 # Years until 

598 if calendarYearsUntilEvent: 

599 if calendarMonthsUntilEvent: 

600 return f"{yearString} and {monthString}" 

601 return f"{yearString}" 

602 # Months until 

603 if calendarMonthsUntilEvent: 

604 if calendarDaysUntilEvent: 

605 return f"{monthString} and {dayString}" 

606 return f"{monthString}" 

607 # Days until 

608 if calendarDaysUntilEvent: 

609 if eventStart.time() < currentDatetime.time(): 

610 if calendarDaysUntilEvent == 1: 

611 return "Tomorrow" 

612 return f"{dayString}" 

613 if timeUntilEvent.hours: 

614 return f"{dayString} and {hourString}" 

615 return f"{dayString}" 

616 # Hours until 

617 if timeUntilEvent.hours: 

618 if timeUntilEvent.minutes: 

619 return f"{hourString} and {minuteString}" 

620 return f"{hourString}" 

621 # Minutes until 

622 elif timeUntilEvent.minutes > 1: 

623 return f"{minuteString}" 

624 # Seconds until 

625 return "<1 minute" 

626 

627def copyRsvpToNewEvent(priorEvent, newEvent): 

628 """ 

629 Copies rvsps from priorEvent to newEvent 

630 """ 

631 rsvpInfo = list(EventRsvp.select().where(EventRsvp.event == priorEvent['id']).execute()) 

632 

633 for student in rsvpInfo: 

634 newRsvp = EventRsvp( 

635 user = student.user, 

636 event = newEvent, 

637 rsvpWaitlist = student.rsvpWaitlist 

638 ) 

639 newRsvp.save() 

640 numRsvps = len(rsvpInfo) 

641 if numRsvps: 

642 createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}") 

643 

644 

645def inviteCohortsToEvent(event, cohortYears): 

646 """ 

647 Invites cohorts to a newly created event by associating the cohorts directly. 

648 """ 

649 invitedCohorts = [] 

650 try: 

651 for year in cohortYears: 

652 year = int(year) 

653 EventCohort.get_or_create( 

654 event=event, 

655 year=year, 

656 defaults={'invited_at': datetime.now()} 

657 ) 

658 

659 addBonnerCohortToRsvpLog(year, event.id) 

660 rsvpForBonnerCohort(year, event.id) 

661 invitedCohorts.append(year) 

662 

663 if invitedCohorts: 

664 cohortList = ', '.join(map(str, invitedCohorts)) 

665 createActivityLog(f"Added Bonner cohorts {cohortList} for newly created event {event.name}") 

666 

667 return True, "Cohorts successfully added to new event", invitedCohorts 

668 

669 except Exception as e: 

670 print(f"Error inviting cohorts to new event: {e}") 

671 return False, f"Error adding cohorts to new event: {e}", [] 

672 

673def updateEventCohorts(event, cohortYears): 

674 """ 

675 Updates the cohorts for an existing event by adding new ones and removing outdated ones. 

676 """ 

677 invitedCohorts = [] 

678 try: 

679 precedentInvitedCohorts = list(EventCohort.select().where(EventCohort.event == event)) 

680 precedentInvitedYears = [precedentCohort.year for precedentCohort in precedentInvitedCohorts] 

681 yearsToAdd = [year for year in cohortYears if int(year) not in precedentInvitedYears] 

682 

683 for year in yearsToAdd: 

684 EventCohort.get_or_create( 

685 event=event, 

686 year=year, 

687 defaults={'invited_at': datetime.now()} 

688 ) 

689 

690 addBonnerCohortToRsvpLog(year, event.id) 

691 rsvpForBonnerCohort(year, event.id) 

692 invitedCohorts.append(year) 

693 

694 if yearsToAdd: 

695 cohortList = ', '.join(map(str, invitedCohorts)) 

696 createActivityLog(f"Updated Bonner cohorts for event {event.name}. Added: {yearsToAdd}") 

697 

698 return True, "Cohorts successfully updated for event", invitedCohorts 

699 

700 except Exception as e: 

701 print(f"Error updating cohorts for event: {e}") 

702 return False, f"Error updating cohorts for event: {e}", [] 

703 

704