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

330 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2025-02-10 14:41 +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 getUpcomingStudentLedCount(term, currentTime): 

242 """ 

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

244 """ 

245 

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

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

248 .where(Program.isStudentLed, 

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

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

251 Event.isCanceled == False) 

252 .group_by(Program.id)) 

253 

254 programCountDict = {} 

255 

256 for programCount in upcomingCount: 

257 programCountDict[programCount.id] = programCount.eventCount 

258 return programCountDict 

259 

260def getTrainingEvents(term, user): 

261 """ 

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

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

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

265 returned is the All Trainings Event. 

266 term: expected to be the ID of a term 

267 user: expected to be the current user 

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

269 """ 

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

271 .join(Program, JOIN.LEFT_OUTER) 

272 .where(Event.isTraining == True, 

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

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

275 

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

277 if hideBonner: 

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

279 

280 return list(trainingQuery.execute()) 

281 

282def getBonnerEvents(term): 

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

284 .join(Program) 

285 .where(Program.isBonnerScholars, 

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

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

288 .execute()) 

289 return bonnerScholarsEvents 

290 

291def getOtherEvents(term): 

292 """ 

293  

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

295 the Other Events section of the Events List page. 

296 :return: A list of Other Event objects 

297 """ 

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

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

300 

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

302 .join(Program, JOIN.LEFT_OUTER) 

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

304 Event.isTraining == False, 

305 Event.isAllVolunteerTraining == False, 

306 ((Program.isOtherCeltsSponsored) | 

307 ((Program.isStudentLed == False) & 

308 (Program.isBonnerScholars == False)))) 

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

310 .execute()) 

311 

312 return otherEvents 

313 

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

315 """ 

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

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

318 :param user: a username or User object 

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

320 Used in testing, defaults to the current timestamp. 

321 :return: A list of Event objects 

322 """ 

323 

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

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

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

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

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

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

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

331 

332 if program: 

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

334 

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

336 

337 eventsList = [] 

338 seriesEventsList = [] 

339 

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

341 for event in events: 

342 if event.seriesId: 

343 if not event.isCanceled: 

344 if event.seriesId not in seriesEventsList: 

345 eventsList.append(event) 

346 seriesEventsList.append(event.seriesId) 

347 else: 

348 if not event.isCanceled: 

349 eventsList.append(event) 

350 

351 return eventsList 

352 

353def getParticipatedEventsForUser(user): 

354 """ 

355 Get all the events a user has participated in. 

356 :param user: a username or User object 

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

358 Used in testing, defaults to the current timestamp. 

359 :return: A list of Event objects 

360 """ 

361 

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

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

364 .join(EventParticipant) 

365 .where(EventParticipant.user == user, 

366 Event.isAllVolunteerTraining == False) 

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

368 

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

370 .join(EventParticipant) 

371 .where(Event.isAllVolunteerTraining == True, 

372 EventParticipant.user == user)) 

373 union = participatedEvents.union_all(allVolunteer) 

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

375 

376 return unionParticipationWithVolunteer 

377 

378def validateNewEventData(data): 

379 """ 

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

381 

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

383 

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

385 """ 

386 

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

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

389 

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

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

392 

393 # Validation if we are inserting a new event 

394 if 'id' not in data: 

395 

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

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

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

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

400 

401 sameEventListCopy = sameEventList.copy() 

402 

403 for event in sameEventListCopy: 

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

405 sameEventList.remove(event) 

406 

407 try: 

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

409 except DoesNotExist as e: 

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

411 if sameEventList: 

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

413 

414 data['valid'] = True 

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

416 

417def calculateNewSeriesId(): 

418 """ 

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

420 """ 

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

422 if maxSeriesId: 

423 return maxSeriesId + 1 

424 return 1 

425 

426def getPreviousSeriesEventData(seriesId): 

427 """ 

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

429  

430 """ 

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

432 .join(EventParticipant) 

433 .join(Event) 

434 .where(Event.seriesId==seriesId)) 

435 return previousEventVolunteers 

436 

437def getRepeatingEventsData(eventData): 

438 """ 

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

440 dictionary of event data. 

441  

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

443 

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

445 """ 

446 

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

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

449 "week": counter+1} 

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

451 

452def preprocessEventData(eventData): 

453 """ 

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

455 

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

457 - checkboxes should be True or False 

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

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

460 - seriesData should be a JSON string 

461 - Look up matching certification requirement if necessary 

462 """ 

463 ## Process checkboxes 

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

465 

466 for checkBox in eventCheckBoxes: 

467 if checkBox not in eventData: 

468 eventData[checkBox] = False 

469 else: 

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

471 

472 ## Process dates 

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

474 for eventDate in eventDates: 

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

476 eventData[eventDate] = '' 

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

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

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

480 eventData[eventDate] = '' 

481 

482 # Process seriesData 

483 if 'seriesData' not in eventData: 

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

485 

486 # Process terms 

487 if 'term' in eventData: 

488 try: 

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

490 except DoesNotExist: 

491 eventData['term'] = '' 

492 

493 # Process requirement 

494 if 'certRequirement' in eventData: 

495 try: 

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

497 except DoesNotExist: 

498 eventData['certRequirement'] = '' 

499 elif 'id' in eventData: 

500 # look up requirement 

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

502 if match: 

503 eventData['certRequirement'] = match.requirement 

504 if 'timeStart' in eventData: 

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

506 

507 if 'timeEnd' in eventData: 

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

509 

510 return eventData 

511 

512def getTomorrowsEvents(): 

513 """Grabs each event that occurs tomorrow""" 

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

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

516 return events 

517 

518def addEventView(viewer,event): 

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

520 if not viewer.isCeltsAdmin: 

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

522 

523def getEventRsvpCountsForTerm(term): 

524 """ 

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

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

527 current RSVPs to that event as the pair. 

528 """ 

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

530 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

532 .group_by(Event.id)) 

533 

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

535 

536 return amountAsDict 

537 

538def getEventRsvpCount(eventId): 

539 """ 

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

541 """ 

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

543 

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

545 """ 

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

547 until the start of the event. 

548 

549 Note about dates: 

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

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

552 tomorrow with no mention of the hour.  

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

554 and hours in actual time. 

555 

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

557 relative to this morning and exclude all hours and minutes 

558 

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

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

561 """ 

562 

563 if currentDatetime is None: 

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

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

566 

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

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

569 

570 if eventEnd < currentDatetime: 

571 return "Already passed" 

572 elif eventStart <= currentDatetime <= eventEnd: 

573 return "Happening now" 

574 

575 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

576 calendarDelta = relativedelta(eventStart, currentMorning) 

577 calendarYearsUntilEvent = calendarDelta.years 

578 calendarMonthsUntilEvent = calendarDelta.months 

579 calendarDaysUntilEvent = calendarDelta.days 

580 

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

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

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

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

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

586 

587 # Years until 

588 if calendarYearsUntilEvent: 

589 if calendarMonthsUntilEvent: 

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

591 return f"{yearString}" 

592 # Months until 

593 if calendarMonthsUntilEvent: 

594 if calendarDaysUntilEvent: 

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

596 return f"{monthString}" 

597 # Days until 

598 if calendarDaysUntilEvent: 

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

600 if calendarDaysUntilEvent == 1: 

601 return "Tomorrow" 

602 return f"{dayString}" 

603 if timeUntilEvent.hours: 

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

605 return f"{dayString}" 

606 # Hours until 

607 if timeUntilEvent.hours: 

608 if timeUntilEvent.minutes: 

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

610 return f"{hourString}" 

611 # Minutes until 

612 elif timeUntilEvent.minutes > 1: 

613 return f"{minuteString}" 

614 # Seconds until 

615 return "<1 minute" 

616 

617def copyRsvpToNewEvent(priorEvent, newEvent): 

618 """ 

619 Copies rvsps from priorEvent to newEvent 

620 """ 

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

622 

623 for student in rsvpInfo: 

624 newRsvp = EventRsvp( 

625 user = student.user, 

626 event = newEvent, 

627 rsvpWaitlist = student.rsvpWaitlist 

628 ) 

629 newRsvp.save() 

630 numRsvps = len(rsvpInfo) 

631 if numRsvps: 

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

633 

634 

635def inviteCohortsToEvent(event, cohortYears): 

636 """ 

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

638 """ 

639 invitedCohorts = [] 

640 try: 

641 for year in cohortYears: 

642 year = int(year) 

643 EventCohort.get_or_create( 

644 event=event, 

645 year=year, 

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

647 ) 

648 

649 addBonnerCohortToRsvpLog(year, event.id) 

650 rsvpForBonnerCohort(year, event.id) 

651 invitedCohorts.append(year) 

652 

653 if invitedCohorts: 

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

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

656 

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

658 

659 except Exception as e: 

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

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

662 

663def updateEventCohorts(event, cohortYears): 

664 """ 

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

666 """ 

667 invitedCohorts = [] 

668 try: 

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

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

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

672 

673 for year in yearsToAdd: 

674 EventCohort.get_or_create( 

675 event=event, 

676 year=year, 

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

678 ) 

679 

680 addBonnerCohortToRsvpLog(year, event.id) 

681 rsvpForBonnerCohort(year, event.id) 

682 invitedCohorts.append(year) 

683 

684 if yearsToAdd: 

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

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

687 

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

689 

690 except Exception as e: 

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

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

693 

694