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

333 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-12-17 17:33 +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 getVolunteerOpportunities(term): 

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

229 .join(Program) 

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

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

232 (Event.isService == True) & 

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

234 ) 

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

236 .execute()) 

237 

238 programs = {} 

239 

240 for event in volunteerOpportunities: 

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

242 

243 return programs 

244 

245def getEngagementEvents(term): 

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

247 .join(Program) 

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

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

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

251 .execute()) 

252 return engagementEvents 

253 

254def getUpcomingVolunteerOpportunitiesCount(term, currentTime): 

255 """ 

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

257 """ 

258 

259 upcomingCount = ( 

260 Program 

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

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

263 .where( 

264 (Event.term == term) & 

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

266 (Event.isService == True) & 

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

268 ((Event.startDate > currentTime) | 

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

270 (Event.isCanceled == False) 

271 ) 

272 .group_by(Program.id) 

273 ) 

274 

275 programCountDict = {} 

276 for programCount in upcomingCount: 

277 programCountDict[programCount.id] = programCount.eventCount 

278 return programCountDict 

279 

280def getTrainingEvents(term, user): 

281 """ 

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

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

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

285 returned is the All Trainings Event. 

286 term: expected to be the ID of a term 

287 user: expected to be the current user 

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

289 """ 

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

291 .join(Program, JOIN.LEFT_OUTER) 

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

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

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

295 

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

297 if hideBonner: 

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

299 

300 return list(trainingQuery.execute()) 

301 

302def getBonnerEvents(term): 

303 bonnerScholarsEvents = list( 

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

305 .join(Program) 

306 .where( 

307 Program.isBonnerScholars, 

308 Event.term == term, 

309 Event.deletionDate == None 

310 ) 

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

312 .execute() 

313 ) 

314 return bonnerScholarsEvents 

315 

316def getCeltsLabor(term): 

317 """ 

318 Labor tab: events explicitly marked as Labor Only. 

319 """ 

320 celtsLabor = list(Event.select() 

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

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

323 .execute()) 

324 return celtsLabor 

325 

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

327 """ 

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

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

330 :param user: a username or User object 

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

332 Used in testing, defaults to the current timestamp. 

333 :return: A list of Event objects 

334 """ 

335 

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

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

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

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

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

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

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

343 

344 if program: 

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

346 

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

348 

349 eventsList = [] 

350 seriesEventsList = [] 

351 

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

353 for event in events: 

354 if event.seriesId: 

355 if not event.isCanceled: 

356 if event.seriesId not in seriesEventsList: 

357 eventsList.append(event) 

358 seriesEventsList.append(event.seriesId) 

359 else: 

360 if not event.isCanceled: 

361 eventsList.append(event) 

362 

363 return eventsList 

364 

365def getParticipatedEventsForUser(user): 

366 """ 

367 Get all the events a user has participated in. 

368 :param user: a username or User object 

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

370 Used in testing, defaults to the current timestamp. 

371 :return: A list of Event objects 

372 """ 

373 

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

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

376 .join(EventParticipant) 

377 .where(EventParticipant.user == user, 

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

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

380 

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

382 .join(EventParticipant) 

383 .where(Event.isAllVolunteerTraining == True, 

384 EventParticipant.user == user)) 

385 union = participatedEvents.union_all(allVolunteer) 

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

387 

388 return unionParticipationWithVolunteer 

389 

390def validateNewEventData(data): 

391 """ 

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

393 

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

395 

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

397 """ 

398 

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

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

401 

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

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

404 

405 # Validation if we are inserting a new event 

406 if 'id' not in data: 

407 

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

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

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

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

412 

413 sameEventListCopy = sameEventList.copy() 

414 

415 for event in sameEventListCopy: 

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

417 sameEventList.remove(event) 

418 

419 try: 

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

421 except DoesNotExist as e: 

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

423 if sameEventList: 

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

425 

426 data['valid'] = True 

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

428 

429def calculateNewSeriesId(): 

430 """ 

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

432 """ 

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

434 if maxSeriesId: 

435 return maxSeriesId + 1 

436 return 1 

437 

438def getPreviousSeriesEventData(seriesId): 

439 """ 

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

441  

442 """ 

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

444 .join(EventParticipant) 

445 .join(Event) 

446 .where(Event.seriesId==seriesId)) 

447 return previousEventVolunteers 

448 

449def getRepeatingEventsData(eventData): 

450 """ 

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

452 dictionary of event data. 

453  

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

455 

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

457 """ 

458 

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

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

461 "week": counter+1} 

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

463 

464def preprocessEventData(eventData): 

465 """ 

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

467 

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

469 - checkboxes should be True or False 

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

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

472 - seriesData should be a JSON string 

473 - Look up matching certification requirement if necessary 

474 """ 

475 ## Process checkboxes 

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

477 

478 for checkBox in eventCheckBoxes: 

479 if checkBox not in eventData: 

480 eventData[checkBox] = False 

481 else: 

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

483 

484 ## Process dates 

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

486 for eventDate in eventDates: 

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

488 eventData[eventDate] = '' 

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

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

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

492 eventData[eventDate] = '' 

493 

494 # Process seriesData 

495 if 'seriesData' not in eventData: 

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

497 

498 # Process terms 

499 if 'term' in eventData: 

500 try: 

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

502 except DoesNotExist: 

503 eventData['term'] = '' 

504 

505 # Process requirement 

506 if 'certRequirement' in eventData: 

507 try: 

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

509 except DoesNotExist: 

510 eventData['certRequirement'] = '' 

511 elif 'id' in eventData: 

512 # look up requirement 

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

514 if match: 

515 eventData['certRequirement'] = match.requirement 

516 if 'timeStart' in eventData: 

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

518 

519 if 'timeEnd' in eventData: 

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

521 

522 return eventData 

523 

524def getTomorrowsEvents(): 

525 """Grabs each event that occurs tomorrow""" 

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

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

528 return events 

529 

530def addEventView(viewer,event): 

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

532 if not viewer.isCeltsAdmin: 

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

534 

535def getEventRsvpCountsForTerm(term): 

536 """ 

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

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

539 current RSVPs to that event as the pair. 

540 """ 

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

542 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

544 .group_by(Event.id)) 

545 

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

547 

548 return amountAsDict 

549 

550def getEventRsvpCount(eventId): 

551 """ 

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

553 """ 

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

555 

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

557 """ 

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

559 until the start of the event. 

560 

561 Note about dates: 

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

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

564 tomorrow with no mention of the hour.  

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

566 and hours in actual time. 

567 

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

569 relative to this morning and exclude all hours and minutes 

570 

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

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

573 """ 

574 

575 if currentDatetime is None: 

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

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

578 

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

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

581 

582 if eventEnd < currentDatetime: 

583 return "Already passed" 

584 elif eventStart <= currentDatetime <= eventEnd: 

585 return "Happening now" 

586 

587 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

588 calendarDelta = relativedelta(eventStart, currentMorning) 

589 calendarYearsUntilEvent = calendarDelta.years 

590 calendarMonthsUntilEvent = calendarDelta.months 

591 calendarDaysUntilEvent = calendarDelta.days 

592 

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

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

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

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

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

598 

599 # Years until 

600 if calendarYearsUntilEvent: 

601 if calendarMonthsUntilEvent: 

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

603 return f"{yearString}" 

604 # Months until 

605 if calendarMonthsUntilEvent: 

606 if calendarDaysUntilEvent: 

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

608 return f"{monthString}" 

609 # Days until 

610 if calendarDaysUntilEvent: 

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

612 if calendarDaysUntilEvent == 1: 

613 return "Tomorrow" 

614 return f"{dayString}" 

615 if timeUntilEvent.hours: 

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

617 return f"{dayString}" 

618 # Hours until 

619 if timeUntilEvent.hours: 

620 if timeUntilEvent.minutes: 

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

622 return f"{hourString}" 

623 # Minutes until 

624 elif timeUntilEvent.minutes > 1: 

625 return f"{minuteString}" 

626 # Seconds until 

627 return "<1 minute" 

628 

629def copyRsvpToNewEvent(priorEvent, newEvent): 

630 """ 

631 Copies rvsps from priorEvent to newEvent 

632 """ 

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

634 

635 for student in rsvpInfo: 

636 newRsvp = EventRsvp( 

637 user = student.user, 

638 event = newEvent, 

639 rsvpWaitlist = student.rsvpWaitlist 

640 ) 

641 newRsvp.save() 

642 numRsvps = len(rsvpInfo) 

643 if numRsvps: 

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

645 

646 

647def inviteCohortsToEvent(event, cohortYears): 

648 """ 

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

650 """ 

651 invitedCohorts = [] 

652 try: 

653 for year in cohortYears: 

654 year = int(year) 

655 EventCohort.get_or_create( 

656 event=event, 

657 year=year, 

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

659 ) 

660 

661 addBonnerCohortToRsvpLog(year, event.id) 

662 rsvpForBonnerCohort(year, event.id) 

663 invitedCohorts.append(year) 

664 

665 if invitedCohorts: 

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

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

668 

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

670 

671 except Exception as e: 

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

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

674 

675def updateEventCohorts(event, cohortYears): 

676 """ 

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

678 """ 

679 invitedCohorts = [] 

680 try: 

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

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

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

684 

685 for year in yearsToAdd: 

686 EventCohort.get_or_create( 

687 event=event, 

688 year=year, 

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

690 ) 

691 

692 addBonnerCohortToRsvpLog(year, event.id) 

693 rsvpForBonnerCohort(year, event.id) 

694 invitedCohorts.append(year) 

695 

696 if yearsToAdd: 

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

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

699 

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

701 

702 except Exception as e: 

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

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

705 

706