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

333 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2025-05-02 15:35 +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 "isTraining": newEventData['isTraining'], 

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

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

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

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

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

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

205 "contactName": newEventData['contactName'] 

206 } 

207 

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

209 # it is a new event.  

210 if isNewEvent: 

211 eventData['program'] = newEventData['program'] 

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

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

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

215 eventRecord = Event.create(**eventData) 

216 else: 

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

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

219 

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

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

222 

223 eventRecords.append(eventRecord) 

224 return eventRecords 

225 

226def getStudentLedEvents(term): 

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

228 .join(Program) 

229 .where(Program.isStudentLed, 

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

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

232 .execute()) 

233 

234 programs = {} 

235 

236 for event in studentLedEvents: 

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

238 

239 return programs 

240 

241def getEngagementEvents(term): 

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

243 .join(Program) 

244 .where(Event.isEngagement, 

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

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

247 .execute()) 

248 return engagementEvents 

249 

250def getUpcomingStudentLedCount(term, currentTime): 

251 """ 

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

253 """ 

254 

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

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

257 .where(Program.isStudentLed, 

258 Event.term == term, Event.deletionDate == None, 

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

260 Event.isCanceled == False) 

261 .group_by(Program.id)) 

262 

263 programCountDict = {} 

264 

265 for programCount in upcomingCount: 

266 programCountDict[programCount.id] = programCount.eventCount 

267 return programCountDict 

268 

269def getTrainingEvents(term, user): 

270 """ 

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

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

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

274 returned is the All Trainings Event. 

275 term: expected to be the ID of a term 

276 user: expected to be the current user 

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

278 """ 

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

280 .join(Program, JOIN.LEFT_OUTER) 

281 .where(Event.isTraining == True, 

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

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

284 

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

286 if hideBonner: 

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

288 

289 return list(trainingQuery.execute()) 

290 

291def getBonnerEvents(term): 

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

293 .join(Program) 

294 .where(Program.isBonnerScholars, 

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

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

297 .execute()) 

298 return bonnerScholarsEvents 

299 

300def getOtherEvents(term): 

301 """ 

302  

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

304 the Other Events section of the Events List page. 

305 :return: A list of Other Event objects 

306 """ 

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

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

309 

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

311 .join(Program, JOIN.LEFT_OUTER) 

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

313 Event.isTraining == False, 

314 Event.isAllVolunteerTraining == False, 

315 ((Program.isOtherCeltsSponsored) | 

316 ((Program.isStudentLed == False) & 

317 (Program.isBonnerScholars == False)))) 

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

319 .execute()) 

320 

321 return otherEvents 

322 

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

324 """ 

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

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

327 :param user: a username or User object 

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

329 Used in testing, defaults to the current timestamp. 

330 :return: A list of Event objects 

331 """ 

332 

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

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

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

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

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

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

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

340 

341 if program: 

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

343 

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

345 

346 eventsList = [] 

347 seriesEventsList = [] 

348 

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

350 for event in events: 

351 if event.seriesId: 

352 if not event.isCanceled: 

353 if event.seriesId not in seriesEventsList: 

354 eventsList.append(event) 

355 seriesEventsList.append(event.seriesId) 

356 else: 

357 if not event.isCanceled: 

358 eventsList.append(event) 

359 

360 return eventsList 

361 

362def getParticipatedEventsForUser(user): 

363 """ 

364 Get all the events a user has participated in. 

365 :param user: a username or User object 

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

367 Used in testing, defaults to the current timestamp. 

368 :return: A list of Event objects 

369 """ 

370 

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

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

373 .join(EventParticipant) 

374 .where(EventParticipant.user == user, 

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

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

377 

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

379 .join(EventParticipant) 

380 .where(Event.isAllVolunteerTraining == True, 

381 EventParticipant.user == user)) 

382 union = participatedEvents.union_all(allVolunteer) 

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

384 

385 return unionParticipationWithVolunteer 

386 

387def validateNewEventData(data): 

388 """ 

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

390 

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

392 

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

394 """ 

395 

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

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

398 

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

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

401 

402 # Validation if we are inserting a new event 

403 if 'id' not in data: 

404 

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

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

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

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

409 

410 sameEventListCopy = sameEventList.copy() 

411 

412 for event in sameEventListCopy: 

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

414 sameEventList.remove(event) 

415 

416 try: 

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

418 except DoesNotExist as e: 

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

420 if sameEventList: 

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

422 

423 data['valid'] = True 

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

425 

426def calculateNewSeriesId(): 

427 """ 

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

429 """ 

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

431 if maxSeriesId: 

432 return maxSeriesId + 1 

433 return 1 

434 

435def getPreviousSeriesEventData(seriesId): 

436 """ 

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

438  

439 """ 

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

441 .join(EventParticipant) 

442 .join(Event) 

443 .where(Event.seriesId==seriesId)) 

444 return previousEventVolunteers 

445 

446def getRepeatingEventsData(eventData): 

447 """ 

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

449 dictionary of event data. 

450  

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

452 

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

454 """ 

455 

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

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

458 "week": counter+1} 

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

460 

461def preprocessEventData(eventData): 

462 """ 

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

464 

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

466 - checkboxes should be True or False 

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

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

469 - seriesData should be a JSON string 

470 - Look up matching certification requirement if necessary 

471 """ 

472 ## Process checkboxes 

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

474 

475 for checkBox in eventCheckBoxes: 

476 if checkBox not in eventData: 

477 eventData[checkBox] = False 

478 else: 

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

480 

481 ## Process dates 

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

483 for eventDate in eventDates: 

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

485 eventData[eventDate] = '' 

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

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

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

489 eventData[eventDate] = '' 

490 

491 # Process seriesData 

492 if 'seriesData' not in eventData: 

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

494 

495 # Process terms 

496 if 'term' in eventData: 

497 try: 

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

499 except DoesNotExist: 

500 eventData['term'] = '' 

501 

502 # Process requirement 

503 if 'certRequirement' in eventData: 

504 try: 

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

506 except DoesNotExist: 

507 eventData['certRequirement'] = '' 

508 elif 'id' in eventData: 

509 # look up requirement 

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

511 if match: 

512 eventData['certRequirement'] = match.requirement 

513 if 'timeStart' in eventData: 

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

515 

516 if 'timeEnd' in eventData: 

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

518 

519 return eventData 

520 

521def getTomorrowsEvents(): 

522 """Grabs each event that occurs tomorrow""" 

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

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

525 return events 

526 

527def addEventView(viewer,event): 

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

529 if not viewer.isCeltsAdmin: 

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

531 

532def getEventRsvpCountsForTerm(term): 

533 """ 

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

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

536 current RSVPs to that event as the pair. 

537 """ 

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

539 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

541 .group_by(Event.id)) 

542 

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

544 

545 return amountAsDict 

546 

547def getEventRsvpCount(eventId): 

548 """ 

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

550 """ 

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

552 

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

554 """ 

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

556 until the start of the event. 

557 

558 Note about dates: 

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

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

561 tomorrow with no mention of the hour.  

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

563 and hours in actual time. 

564 

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

566 relative to this morning and exclude all hours and minutes 

567 

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

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

570 """ 

571 

572 if currentDatetime is None: 

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

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

575 

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

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

578 

579 if eventEnd < currentDatetime: 

580 return "Already passed" 

581 elif eventStart <= currentDatetime <= eventEnd: 

582 return "Happening now" 

583 

584 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

585 calendarDelta = relativedelta(eventStart, currentMorning) 

586 calendarYearsUntilEvent = calendarDelta.years 

587 calendarMonthsUntilEvent = calendarDelta.months 

588 calendarDaysUntilEvent = calendarDelta.days 

589 

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

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

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

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

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

595 

596 # Years until 

597 if calendarYearsUntilEvent: 

598 if calendarMonthsUntilEvent: 

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

600 return f"{yearString}" 

601 # Months until 

602 if calendarMonthsUntilEvent: 

603 if calendarDaysUntilEvent: 

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

605 return f"{monthString}" 

606 # Days until 

607 if calendarDaysUntilEvent: 

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

609 if calendarDaysUntilEvent == 1: 

610 return "Tomorrow" 

611 return f"{dayString}" 

612 if timeUntilEvent.hours: 

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

614 return f"{dayString}" 

615 # Hours until 

616 if timeUntilEvent.hours: 

617 if timeUntilEvent.minutes: 

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

619 return f"{hourString}" 

620 # Minutes until 

621 elif timeUntilEvent.minutes > 1: 

622 return f"{minuteString}" 

623 # Seconds until 

624 return "<1 minute" 

625 

626def copyRsvpToNewEvent(priorEvent, newEvent): 

627 """ 

628 Copies rvsps from priorEvent to newEvent 

629 """ 

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

631 

632 for student in rsvpInfo: 

633 newRsvp = EventRsvp( 

634 user = student.user, 

635 event = newEvent, 

636 rsvpWaitlist = student.rsvpWaitlist 

637 ) 

638 newRsvp.save() 

639 numRsvps = len(rsvpInfo) 

640 if numRsvps: 

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

642 

643 

644def inviteCohortsToEvent(event, cohortYears): 

645 """ 

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

647 """ 

648 invitedCohorts = [] 

649 try: 

650 for year in cohortYears: 

651 year = int(year) 

652 EventCohort.get_or_create( 

653 event=event, 

654 year=year, 

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

656 ) 

657 

658 addBonnerCohortToRsvpLog(year, event.id) 

659 rsvpForBonnerCohort(year, event.id) 

660 invitedCohorts.append(year) 

661 

662 if invitedCohorts: 

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

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

665 

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

667 

668 except Exception as e: 

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

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

671 

672def updateEventCohorts(event, cohortYears): 

673 """ 

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

675 """ 

676 invitedCohorts = [] 

677 try: 

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

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

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

681 

682 for year in yearsToAdd: 

683 EventCohort.get_or_create( 

684 event=event, 

685 year=year, 

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

687 ) 

688 

689 addBonnerCohortToRsvpLog(year, event.id) 

690 rsvpForBonnerCohort(year, event.id) 

691 invitedCohorts.append(year) 

692 

693 if yearsToAdd: 

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

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

696 

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

698 

699 except Exception as e: 

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

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

702 

703