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

333 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2025-07-22 20:03 +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 getStudentLedEvents(term): 

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

230 .join(Program) 

231 .where(Program.isStudentLed, 

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

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

234 .execute()) 

235 

236 programs = {} 

237 

238 for event in studentLedEvents: 

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

240 

241 return programs 

242 

243def getEngagementEvents(term): 

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

245 .join(Program) 

246 .where(Event.isEngagement, 

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

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

249 .execute()) 

250 return engagementEvents 

251 

252def getUpcomingStudentLedCount(term, currentTime): 

253 """ 

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

255 """ 

256 

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

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

259 .where(Program.isStudentLed, 

260 Event.term == term, Event.deletionDate == None, 

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

262 Event.isCanceled == False) 

263 .group_by(Program.id)) 

264 

265 programCountDict = {} 

266 

267 for programCount in upcomingCount: 

268 programCountDict[programCount.id] = programCount.eventCount 

269 return programCountDict 

270 

271def getTrainingEvents(term, user): 

272 """ 

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

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

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

276 returned is the All Trainings Event. 

277 term: expected to be the ID of a term 

278 user: expected to be the current user 

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

280 """ 

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

282 .join(Program, JOIN.LEFT_OUTER) 

283 .where(Event.isTraining == True, 

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

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

286 

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

288 if hideBonner: 

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

290 

291 return list(trainingQuery.execute()) 

292 

293def getBonnerEvents(term): 

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

295 .join(Program) 

296 .where(Program.isBonnerScholars, 

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

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

299 .execute()) 

300 return bonnerScholarsEvents 

301 

302def getOtherEvents(term): 

303 """ 

304  

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

306 the Other Events section of the Events List page. 

307 :return: A list of Other Event objects 

308 """ 

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

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

311 

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

313 .join(Program, JOIN.LEFT_OUTER) 

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

315 Event.isTraining == False, 

316 Event.isAllVolunteerTraining == False, 

317 ((Program.isOtherCeltsSponsored) | 

318 ((Program.isStudentLed == False) & 

319 (Program.isBonnerScholars == False)))) 

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

321 .execute()) 

322 

323 return otherEvents 

324 

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

326 """ 

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

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

329 :param user: a username or User object 

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

331 Used in testing, defaults to the current timestamp. 

332 :return: A list of Event objects 

333 """ 

334 

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

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

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

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

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

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

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

342 

343 if program: 

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

345 

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

347 

348 eventsList = [] 

349 seriesEventsList = [] 

350 

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

352 for event in events: 

353 if event.seriesId: 

354 if not event.isCanceled: 

355 if event.seriesId not in seriesEventsList: 

356 eventsList.append(event) 

357 seriesEventsList.append(event.seriesId) 

358 else: 

359 if not event.isCanceled: 

360 eventsList.append(event) 

361 

362 return eventsList 

363 

364def getParticipatedEventsForUser(user): 

365 """ 

366 Get all the events a user has participated in. 

367 :param user: a username or User object 

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

369 Used in testing, defaults to the current timestamp. 

370 :return: A list of Event objects 

371 """ 

372 

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

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

375 .join(EventParticipant) 

376 .where(EventParticipant.user == user, 

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

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

379 

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

381 .join(EventParticipant) 

382 .where(Event.isAllVolunteerTraining == True, 

383 EventParticipant.user == user)) 

384 union = participatedEvents.union_all(allVolunteer) 

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

386 

387 return unionParticipationWithVolunteer 

388 

389def validateNewEventData(data): 

390 """ 

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

392 

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

394 

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

396 """ 

397 

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

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

400 

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

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

403 

404 # Validation if we are inserting a new event 

405 if 'id' not in data: 

406 

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

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

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

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

411 

412 sameEventListCopy = sameEventList.copy() 

413 

414 for event in sameEventListCopy: 

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

416 sameEventList.remove(event) 

417 

418 try: 

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

420 except DoesNotExist as e: 

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

422 if sameEventList: 

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

424 

425 data['valid'] = True 

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

427 

428def calculateNewSeriesId(): 

429 """ 

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

431 """ 

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

433 if maxSeriesId: 

434 return maxSeriesId + 1 

435 return 1 

436 

437def getPreviousSeriesEventData(seriesId): 

438 """ 

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

440  

441 """ 

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

443 .join(EventParticipant) 

444 .join(Event) 

445 .where(Event.seriesId==seriesId)) 

446 return previousEventVolunteers 

447 

448def getRepeatingEventsData(eventData): 

449 """ 

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

451 dictionary of event data. 

452  

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

454 

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

456 """ 

457 

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

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

460 "week": counter+1, 

461 'location': eventData['location'] 

462 } 

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

464 

465def preprocessEventData(eventData): 

466 """ 

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

468 

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

470 - checkboxes should be True or False 

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

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

473 - seriesData should be a JSON string 

474 - Look up matching certification requirement if necessary 

475 """ 

476 ## Process checkboxes 

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

478 

479 for checkBox in eventCheckBoxes: 

480 if checkBox not in eventData: 

481 eventData[checkBox] = False 

482 else: 

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

484 

485 ## Process dates 

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

487 for eventDate in eventDates: 

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

489 eventData[eventDate] = '' 

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

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

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

493 eventData[eventDate] = '' 

494 

495 # Process seriesData 

496 if 'seriesData' not in eventData: 

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

498 

499 # Process terms 

500 if 'term' in eventData: 

501 try: 

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

503 except DoesNotExist: 

504 eventData['term'] = '' 

505 

506 # Process requirement 

507 if 'certRequirement' in eventData: 

508 try: 

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

510 except DoesNotExist: 

511 eventData['certRequirement'] = '' 

512 elif 'id' in eventData: 

513 # look up requirement 

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

515 if match: 

516 eventData['certRequirement'] = match.requirement 

517 if 'timeStart' in eventData: 

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

519 

520 if 'timeEnd' in eventData: 

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

522 

523 return eventData 

524 

525def getTomorrowsEvents(): 

526 """Grabs each event that occurs tomorrow""" 

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

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

529 return events 

530 

531def addEventView(viewer,event): 

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

533 if not viewer.isCeltsAdmin: 

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

535 

536def getEventRsvpCountsForTerm(term): 

537 """ 

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

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

540 current RSVPs to that event as the pair. 

541 """ 

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

543 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

545 .group_by(Event.id)) 

546 

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

548 

549 return amountAsDict 

550 

551def getEventRsvpCount(eventId): 

552 """ 

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

554 """ 

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

556 

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

558 """ 

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

560 until the start of the event. 

561 

562 Note about dates: 

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

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

565 tomorrow with no mention of the hour.  

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

567 and hours in actual time. 

568 

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

570 relative to this morning and exclude all hours and minutes 

571 

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

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

574 """ 

575 

576 if currentDatetime is None: 

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

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

579 

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

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

582 

583 if eventEnd < currentDatetime: 

584 return "Already passed" 

585 elif eventStart <= currentDatetime <= eventEnd: 

586 return "Happening now" 

587 

588 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

589 calendarDelta = relativedelta(eventStart, currentMorning) 

590 calendarYearsUntilEvent = calendarDelta.years 

591 calendarMonthsUntilEvent = calendarDelta.months 

592 calendarDaysUntilEvent = calendarDelta.days 

593 

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

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

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

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

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

599 

600 # Years until 

601 if calendarYearsUntilEvent: 

602 if calendarMonthsUntilEvent: 

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

604 return f"{yearString}" 

605 # Months until 

606 if calendarMonthsUntilEvent: 

607 if calendarDaysUntilEvent: 

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

609 return f"{monthString}" 

610 # Days until 

611 if calendarDaysUntilEvent: 

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

613 if calendarDaysUntilEvent == 1: 

614 return "Tomorrow" 

615 return f"{dayString}" 

616 if timeUntilEvent.hours: 

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

618 return f"{dayString}" 

619 # Hours until 

620 if timeUntilEvent.hours: 

621 if timeUntilEvent.minutes: 

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

623 return f"{hourString}" 

624 # Minutes until 

625 elif timeUntilEvent.minutes > 1: 

626 return f"{minuteString}" 

627 # Seconds until 

628 return "<1 minute" 

629 

630def copyRsvpToNewEvent(priorEvent, newEvent): 

631 """ 

632 Copies rvsps from priorEvent to newEvent 

633 """ 

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

635 

636 for student in rsvpInfo: 

637 newRsvp = EventRsvp( 

638 user = student.user, 

639 event = newEvent, 

640 rsvpWaitlist = student.rsvpWaitlist 

641 ) 

642 newRsvp.save() 

643 numRsvps = len(rsvpInfo) 

644 if numRsvps: 

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

646 

647 

648def inviteCohortsToEvent(event, cohortYears): 

649 """ 

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

651 """ 

652 invitedCohorts = [] 

653 try: 

654 for year in cohortYears: 

655 year = int(year) 

656 EventCohort.get_or_create( 

657 event=event, 

658 year=year, 

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

660 ) 

661 

662 addBonnerCohortToRsvpLog(year, event.id) 

663 rsvpForBonnerCohort(year, event.id) 

664 invitedCohorts.append(year) 

665 

666 if invitedCohorts: 

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

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

669 

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

671 

672 except Exception as e: 

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

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

675 

676def updateEventCohorts(event, cohortYears): 

677 """ 

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

679 """ 

680 invitedCohorts = [] 

681 try: 

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

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

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

685 

686 for year in yearsToAdd: 

687 EventCohort.get_or_create( 

688 event=event, 

689 year=year, 

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

691 ) 

692 

693 addBonnerCohortToRsvpLog(year, event.id) 

694 rsvpForBonnerCohort(year, event.id) 

695 invitedCohorts.append(year) 

696 

697 if yearsToAdd: 

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

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

700 

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

702 

703 except Exception as e: 

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

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

706 

707