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

333 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-12-21 23:52 +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 'location': eventData['location'], 

133 'seriesId': seriesId, 

134 'isRepeating': bool(isRepeating), 

135 }) 

136 # Try to save each offering 

137 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) 

138 if validationErrorMessage: 

139 failedSavedOfferings.append((index, validationErrorMessage)) 

140 allSavesWereSuccessful = False 

141 else: 

142 savedEvent = savedEvents[0] 

143 savedOfferings.append(savedEvent) 

144 if not allSavesWereSuccessful: 

145 savedOfferings = [] 

146 transaction.rollback() 

147 

148 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings 

149 

150 

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

152 """ 

153 Tries to save an event to the database: 

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

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

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

157 

158 Returns: 

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

160 """ 

161 

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

163 # automatically changed from "" to 0 

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

165 eventData["rsvpLimit"] = None 

166 

167 newEventData = preprocessEventData(eventData) 

168 

169 isValid, validationErrorMessage = validateNewEventData(newEventData) 

170 if not isValid: 

171 return [], validationErrorMessage 

172 

173 events = saveEventToDb(newEventData, renewedEvent) 

174 if attachmentFiles: 

175 for event in events: 

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

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

178 return events, "" 

179 

180 

181def saveEventToDb(newEventData, renewedEvent = False): 

182 

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

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

185 

186 isNewEvent = ('id' not in newEventData) 

187 

188 eventRecords = [] 

189 with mainDB.atomic(): 

190 

191 eventData = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

208 } 

209 

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

211 # it is a new event.  

212 if isNewEvent: 

213 eventData['program'] = newEventData['program'] 

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

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

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

217 eventRecord = Event.create(**eventData) 

218 else: 

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

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

221 

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

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

224 

225 eventRecords.append(eventRecord) 

226 return eventRecords 

227 

228def getVolunteerOpportunities(term): 

229 volunteerOpportunities = list(Event.select(Event, Program) 

230 .join(Program) 

231 .where((Event.term == term) & 

232 (Event.deletionDate.is_null(True)) & 

233 (Event.isService == True) & 

234 ((Event.isLaborOnly == False) | Event.isLaborOnly.is_null(True)) 

235 ) 

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

237 .execute()) 

238 

239 programs = {} 

240 

241 for event in volunteerOpportunities: 

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

243 

244 return programs 

245 

246def getEngagementEvents(term): 

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

248 .join(Program) 

249 .where(Event.isEngagement, Event.isLaborOnly == False, 

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

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

252 .execute()) 

253 return engagementEvents 

254 

255def getUpcomingVolunteerOpportunitiesCount(term, currentTime): 

256 """ 

257 Return a count of all upcoming events for each volunteer opportunitiesprogram. 

258 """ 

259 

260 upcomingCount = ( 

261 Program 

262 .select(Program.id, fn.COUNT(Event.id).alias("eventCount")) 

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

264 .where( 

265 (Event.term == term) & 

266 (Event.deletionDate.is_null(True)) & 

267 (Event.isService == True) & 

268 ((Event.isLaborOnly == False) | Event.isLaborOnly.is_null(True)) & 

269 ((Event.startDate > currentTime) | 

270 ((Event.startDate == currentTime) & (Event.timeEnd >= currentTime))) & 

271 (Event.isCanceled == False) 

272 ) 

273 .group_by(Program.id) 

274 ) 

275 

276 programCountDict = {} 

277 for programCount in upcomingCount: 

278 programCountDict[programCount.id] = programCount.eventCount 

279 return programCountDict 

280 

281def getTrainingEvents(term, user): 

282 """ 

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

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

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

286 returned is the All Trainings Event. 

287 term: expected to be the ID of a term 

288 user: expected to be the current user 

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

290 """ 

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

292 .join(Program, JOIN.LEFT_OUTER) 

293 .where(Event.isTraining == True, Event.isLaborOnly == False, 

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

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

296 

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

298 if hideBonner: 

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

300 

301 return list(trainingQuery.execute()) 

302 

303def getBonnerEvents(term): 

304 bonnerScholarsEvents = list( 

305 Event.select(Event, Program.id.alias("program_id")) 

306 .join(Program) 

307 .where( 

308 Program.isBonnerScholars, 

309 Event.term == term, 

310 Event.deletionDate == None 

311 ) 

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

313 .execute() 

314 ) 

315 return bonnerScholarsEvents 

316 

317def getCeltsLabor(term): 

318 """ 

319 Labor tab: events explicitly marked as Labor Only. 

320 """ 

321 celtsLabor = list(Event.select() 

322 .where(Event.term == term, Event.deletionDate == None, Event.isLaborOnly == True) 

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

324 .execute()) 

325 return celtsLabor 

326 

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

328 """ 

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

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

331 :param user: a username or User object 

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

333 Used in testing, defaults to the current timestamp. 

334 :return: A list of Event objects 

335 """ 

336 

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

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

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

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

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

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

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

344 

345 if program: 

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

347 

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

349 

350 eventsList = [] 

351 seriesEventsList = [] 

352 

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

354 for event in events: 

355 if event.seriesId: 

356 if not event.isCanceled: 

357 if event.seriesId not in seriesEventsList: 

358 eventsList.append(event) 

359 seriesEventsList.append(event.seriesId) 

360 else: 

361 if not event.isCanceled: 

362 eventsList.append(event) 

363 

364 return eventsList 

365 

366def getParticipatedEventsForUser(user): 

367 """ 

368 Get all the events a user has participated in. 

369 :param user: a username or User object 

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

371 Used in testing, defaults to the current timestamp. 

372 :return: A list of Event objects 

373 """ 

374 

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

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

377 .join(EventParticipant) 

378 .where(EventParticipant.user == user, 

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

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

381 

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

383 .join(EventParticipant) 

384 .where(Event.isAllVolunteerTraining == True, 

385 EventParticipant.user == user)) 

386 union = participatedEvents.union_all(allVolunteer) 

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

388 

389 return unionParticipationWithVolunteer 

390 

391def validateNewEventData(data): 

392 """ 

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

394 

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

396 

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

398 """ 

399 

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

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

402 

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

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

405 

406 # Validation if we are inserting a new event 

407 if 'id' not in data: 

408 

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

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

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

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

413 

414 sameEventListCopy = sameEventList.copy() 

415 

416 for event in sameEventListCopy: 

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

418 sameEventList.remove(event) 

419 

420 try: 

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

422 except DoesNotExist as e: 

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

424 if sameEventList: 

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

426 

427 data['valid'] = True 

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

429 

430def calculateNewSeriesId(): 

431 """ 

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

433 """ 

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

435 if maxSeriesId: 

436 return maxSeriesId + 1 

437 return 1 

438 

439def getPreviousSeriesEventData(seriesId): 

440 """ 

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

442  

443 """ 

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

445 .join(EventParticipant) 

446 .join(Event) 

447 .where(Event.seriesId==seriesId)) 

448 return previousEventVolunteers 

449 

450def getRepeatingEventsData(eventData): 

451 """ 

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

453 dictionary of event data. 

454  

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

456 

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

458 """ 

459 

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

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

462 "week": counter+1, 

463 'location': eventData['location'] 

464 } 

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

466 

467def preprocessEventData(eventData): 

468 """ 

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

470 

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

472 - checkboxes should be True or False 

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

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

475 - seriesData should be a JSON string 

476 - Look up matching certification requirement if necessary 

477 """ 

478 ## Process checkboxes 

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

480 

481 for checkBox in eventCheckBoxes: 

482 if checkBox not in eventData: 

483 eventData[checkBox] = False 

484 else: 

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

486 

487 ## Process dates 

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

489 for eventDate in eventDates: 

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

491 eventData[eventDate] = '' 

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

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

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

495 eventData[eventDate] = '' 

496 

497 # Process seriesData 

498 if 'seriesData' not in eventData: 

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

500 

501 # Process terms 

502 if 'term' in eventData: 

503 try: 

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

505 except DoesNotExist: 

506 eventData['term'] = '' 

507 

508 # Process requirement 

509 if 'certRequirement' in eventData: 

510 try: 

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

512 except DoesNotExist: 

513 eventData['certRequirement'] = '' 

514 elif 'id' in eventData: 

515 # look up requirement 

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

517 if match: 

518 eventData['certRequirement'] = match.requirement 

519 if 'timeStart' in eventData: 

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

521 

522 if 'timeEnd' in eventData: 

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

524 

525 return eventData 

526 

527def getTomorrowsEvents(): 

528 """Grabs each event that occurs tomorrow""" 

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

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

531 return events 

532 

533def addEventView(viewer,event): 

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

535 if not viewer.isCeltsAdmin: 

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

537 

538def getEventRsvpCountsForTerm(term): 

539 """ 

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

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

542 current RSVPs to that event as the pair. 

543 """ 

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

545 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

547 .group_by(Event.id)) 

548 

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

550 

551 return amountAsDict 

552 

553def getEventRsvpCount(eventId): 

554 """ 

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

556 """ 

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

558 

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

560 """ 

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

562 until the start of the event. 

563 

564 Note about dates: 

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

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

567 tomorrow with no mention of the hour.  

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

569 and hours in actual time. 

570 

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

572 relative to this morning and exclude all hours and minutes 

573 

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

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

576 """ 

577 

578 if currentDatetime is None: 

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

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

581 

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

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

584 

585 if eventEnd < currentDatetime: 

586 return "Already passed" 

587 elif eventStart <= currentDatetime <= eventEnd: 

588 return "Happening now" 

589 

590 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

591 calendarDelta = relativedelta(eventStart, currentMorning) 

592 calendarYearsUntilEvent = calendarDelta.years 

593 calendarMonthsUntilEvent = calendarDelta.months 

594 calendarDaysUntilEvent = calendarDelta.days 

595 

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

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

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

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

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

601 

602 # Years until 

603 if calendarYearsUntilEvent: 

604 if calendarMonthsUntilEvent: 

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

606 return f"{yearString}" 

607 # Months until 

608 if calendarMonthsUntilEvent: 

609 if calendarDaysUntilEvent: 

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

611 return f"{monthString}" 

612 # Days until 

613 if calendarDaysUntilEvent: 

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

615 if calendarDaysUntilEvent == 1: 

616 return "Tomorrow" 

617 return f"{dayString}" 

618 if timeUntilEvent.hours: 

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

620 return f"{dayString}" 

621 # Hours until 

622 if timeUntilEvent.hours: 

623 if timeUntilEvent.minutes: 

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

625 return f"{hourString}" 

626 # Minutes until 

627 elif timeUntilEvent.minutes > 1: 

628 return f"{minuteString}" 

629 # Seconds until 

630 return "<1 minute" 

631 

632def copyRsvpToNewEvent(priorEvent, newEvent): 

633 """ 

634 Copies rvsps from priorEvent to newEvent 

635 """ 

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

637 

638 for student in rsvpInfo: 

639 newRsvp = EventRsvp( 

640 user = student.user, 

641 event = newEvent, 

642 rsvpWaitlist = student.rsvpWaitlist 

643 ) 

644 newRsvp.save() 

645 numRsvps = len(rsvpInfo) 

646 if numRsvps: 

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

648 

649 

650def inviteCohortsToEvent(event, cohortYears): 

651 """ 

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

653 """ 

654 invitedCohorts = [] 

655 try: 

656 for year in cohortYears: 

657 year = int(year) 

658 EventCohort.get_or_create( 

659 event=event, 

660 year=year, 

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

662 ) 

663 

664 addBonnerCohortToRsvpLog(year, event.id) 

665 rsvpForBonnerCohort(year, event.id) 

666 invitedCohorts.append(year) 

667 

668 if invitedCohorts: 

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

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

671 

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

673 

674 except Exception as e: 

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

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

677 

678def updateEventCohorts(event, cohortYears): 

679 """ 

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

681 """ 

682 invitedCohorts = [] 

683 try: 

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

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

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

687 

688 for year in yearsToAdd: 

689 EventCohort.get_or_create( 

690 event=event, 

691 year=year, 

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

693 ) 

694 

695 addBonnerCohortToRsvpLog(year, event.id) 

696 rsvpForBonnerCohort(year, event.id) 

697 invitedCohorts.append(year) 

698 

699 if yearsToAdd: 

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

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

702 

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

704 

705 except Exception as e: 

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

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

708 

709