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

341 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2026-05-07 16:31 +0000

1from flask import url_for, g, session 

2from peewee import DoesNotExist, fn, JOIN, Case, Value 

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 for each event in the series, inheriting from the original eventData 

122 seriesData = sorted(eventData.get('seriesData'), key=lambda x: datetime.strptime(x['eventDate'].split(' ')[0] + ' ' + x['startTime'], '%Y-%m-%d %H:%M')) 

123 # sorts the events in the series by date and time so that the events are created in order and the naming convention of Week 1, Week 2, etc. is consistent with the order of the events. 

124 isRepeating = bool(eventData.get('isRepeating')) 

125 with mainDB.atomic() as transaction: 

126 for index, event in enumerate(seriesData): 

127 eventInfo = eventData.copy() 

128 eventInfo.update({ 

129 'name': event['eventName'], 

130 'startDate': event['eventDate'], 

131 'timeStart': event['startTime'], 

132 'timeEnd': event['endTime'], 

133 'location': event['eventLocation'], 

134 'seriesId': seriesId, 

135 'isRepeating': bool(isRepeating), 

136 }) 

137 # Try to save each offering 

138 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) 

139 if validationErrorMessage: 

140 failedSavedOfferings.append((index, validationErrorMessage)) 

141 allSavesWereSuccessful = False 

142 else: 

143 savedEvent = savedEvents[0] 

144 savedOfferings.append(savedEvent) 

145 if not allSavesWereSuccessful: 

146 savedOfferings = [] 

147 transaction.rollback() 

148 

149 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings 

150 

151 

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

153 """ 

154 Tries to save an event to the database: 

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

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

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

158 

159 Returns: 

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

161 """ 

162 

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

164 # automatically changed from "" to 0 

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

166 eventData["rsvpLimit"] = None 

167 

168 newEventData = preprocessEventData(eventData) 

169 

170 isValid, validationErrorMessage = validateNewEventData(newEventData) 

171 if not isValid: 

172 return [], validationErrorMessage 

173 

174 events = saveEventToDb(newEventData, renewedEvent) 

175 if attachmentFiles: 

176 for event in events: 

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

178 addFile.saveFiles(parentEvent=events[0]) 

179 return events, "" 

180 

181 

182def saveEventToDb(newEventData, renewedEvent = False): 

183 

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

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

186 

187 isNewEvent = ('id' not in newEventData) 

188 

189 eventRecords = [] 

190 with mainDB.atomic(): 

191 

192 eventData = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

209 } 

210 

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

212 # it is a new event.  

213 if isNewEvent: 

214 eventData['program'] = newEventData['program'] 

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

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

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

218 eventRecord = Event.create(**eventData) 

219 else: 

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

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

222 

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

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

225 

226 eventRecords.append(eventRecord) 

227 return eventRecords 

228 

229def getVolunteerOpportunities(term): 

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

231 .join(Program) 

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

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

234 (Event.isService == True) & 

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

236 ) 

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

238 .execute()) 

239 

240 programs = {} 

241 

242 for event in volunteerOpportunities: 

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

244 

245 return programs 

246 

247def getEngagementEvents(term): 

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

249 .join(Program) 

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

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

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

253 .execute()) 

254 return engagementEvents 

255 

256def getUpcomingVolunteerOpportunitiesCount(term, currentDate): 

257 """ 

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

259 """ 

260 

261 upcomingCount = ( 

262 Program 

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

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

265 .where( 

266 (Event.term == term) & 

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

268 (Event.isService == True) & 

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

270 ((Event.startDate > currentDate) | 

271 ((Event.startDate == currentDate) & (Event.timeEnd >= currentDate.time()))) & 

272 (Event.isCanceled == False) 

273 ) 

274 .group_by(Program.id) 

275 ) 

276 

277 programCountDict = {} 

278 for programCount in upcomingCount: 

279 programCountDict[programCount.id] = programCount.eventCount 

280 return programCountDict 

281 

282def getPastVolunteerOpportunitiesCount(term, currentDate): 

283 """ 

284 Return a count of all past events for each volunteer opportunities program. 

285 """ 

286 

287 pastCount = ( 

288 Program 

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

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

291 .where( 

292 (Event.term == term) & 

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

294 (Event.isService == True) & 

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

296 ((Event.startDate < currentDate) | 

297 ((Event.startDate == currentDate) & (Event.timeStart <= currentDate.time()))) & 

298 (Event.isCanceled == False) 

299 ) 

300 .group_by(Program.id) 

301 ) 

302 

303 programCountDict = {} 

304 for programCount in pastCount: 

305 programCountDict[programCount.id] = programCount.eventCount 

306 return programCountDict 

307 

308def getTrainingEvents(term, user): 

309 """ 

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

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

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

313 returned is the All Trainings Event. 

314 term: expected to be the ID of a term 

315 user: expected to be the current user 

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

317 """ 

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

319 .join(Program, JOIN.LEFT_OUTER) 

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

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

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

323 

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

325 if hideBonner: 

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

327 

328 return list(trainingQuery.execute()) 

329 

330def getBonnerEvents(term): 

331 bonnerScholarsEvents = list( 

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

333 .join(Program) 

334 .where( 

335 Program.isBonnerScholars, 

336 Event.term == term, 

337 Event.deletionDate == None 

338 ) 

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

340 .execute() 

341 ) 

342 return bonnerScholarsEvents 

343 

344def getCeltsLabor(term): 

345 """ 

346 Labor tab: events explicitly marked as Labor Only. 

347 """ 

348 celtsLabor = list(Event.select() 

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

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

351 .execute()) 

352 return celtsLabor 

353 

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

355 """ 

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

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

358 :param user: a username or User object 

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

360 Used in testing, defaults to the current timestamp. 

361 :return: A list of Event objects 

362 """ 

363 

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

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

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

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

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

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

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

371 

372 if program: 

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

374 

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

376 

377 eventsList = [] 

378 seriesEventsList = [] 

379 

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

381 for event in events: 

382 if event.seriesId: 

383 if not event.isCanceled: 

384 if event.seriesId not in seriesEventsList: 

385 eventsList.append(event) 

386 seriesEventsList.append(event.seriesId) 

387 else: 

388 if not event.isCanceled: 

389 eventsList.append(event) 

390 

391 return eventsList 

392 

393def getParticipatedEventsForUser(user): 

394 """ 

395 Get all the events a user has participated in. 

396 :param user: a username or User object 

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

398 Used in testing, defaults to the current timestamp. 

399 :return: A list of Event objects 

400 """ 

401 

402 eventName = fn.LOWER(Event.name) 

403 checkIfLaborMeeting = eventName.contains("labor meeting") 

404 

405 participatedEvents = (Event.select(Event, Program.programName, Case(None, ( 

406 ((Event.isLaborOnly | Event.name.contains("Labor")) & Event.isService, "Labor & Volunteer"), 

407 ((Event.isLaborOnly | Event.name.contains("Labor")), "Labor"), 

408 (Event.isService, "Volunteer")), "Attendee").alias("participatedType")) 

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

410 .join(EventParticipant) 

411 .where(EventParticipant.user == user, 

412 Event.isAllVolunteerTraining == False, Event.deletionDate == None, ~checkIfLaborMeeting) 

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

414 allVolunteer = (Event.select(Event, "", Value("Volunteer").alias("participatedType")) 

415 .join(EventParticipant) 

416 .where(Event.isAllVolunteerTraining == True, 

417 EventParticipant.user == user)) 

418 union = participatedEvents.union_all(allVolunteer) 

419 unionParticipationWithVolunteer = list(union.select_from(union.c.id, union.c.programName, union.c.startDate, union.c.name, union.c.participatedType).order_by(union.c.startDate, union.c.name).execute()) 

420 return unionParticipationWithVolunteer 

421 

422def validateNewEventData(data): 

423 """ 

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

425 

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

427 

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

429 """ 

430 

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

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

433 

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

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

436 

437 # Validation if we are inserting a new event 

438 if 'id' not in data: 

439 

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

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

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

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

444 

445 sameEventListCopy = sameEventList.copy() 

446 

447 for event in sameEventListCopy: 

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

449 sameEventList.remove(event) 

450 

451 try: 

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

453 except DoesNotExist as e: 

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

455 if sameEventList: 

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

457 

458 data['valid'] = True 

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

460 

461def calculateNewSeriesId(): 

462 """ 

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

464 """ 

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

466 if maxSeriesId: 

467 return maxSeriesId + 1 

468 return 1 

469 

470def getPreviousSeriesEventData(seriesId): 

471 """ 

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

473  

474 """ 

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

476 .join(EventParticipant) 

477 .join(Event) 

478 .where(Event.seriesId==seriesId)) 

479 return previousEventVolunteers 

480 

481def getRepeatingEventsData(eventData): 

482 """ 

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

484 dictionary of event data. 

485  

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

487 

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

489 """ 

490 

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

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

493 "week": counter+1, 

494 'location': eventData['location'] 

495 } 

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

497 

498def preprocessEventData(eventData): 

499 """ 

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

501 

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

503 - checkboxes should be True or False 

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

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

506 - seriesData should be a JSON string 

507 - Look up matching certification requirement if necessary 

508 """ 

509 ## Process checkboxes 

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

511 

512 for checkBox in eventCheckBoxes: 

513 if checkBox not in eventData: 

514 eventData[checkBox] = False 

515 else: 

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

517 

518 ## Process dates 

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

520 for eventDate in eventDates: 

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

522 eventData[eventDate] = '' 

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

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

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

526 eventData[eventDate] = '' 

527 

528 # Process seriesData 

529 if 'seriesData' not in eventData: 

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

531 

532 # Process terms 

533 if 'term' in eventData: 

534 try: 

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

536 except DoesNotExist: 

537 eventData['term'] = '' 

538 

539 # Process requirement 

540 if 'certRequirement' in eventData: 

541 try: 

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

543 except DoesNotExist: 

544 eventData['certRequirement'] = '' 

545 elif 'id' in eventData: 

546 # look up requirement 

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

548 if match: 

549 eventData['certRequirement'] = match.requirement 

550 if 'timeStart' in eventData: 

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

552 

553 if 'timeEnd' in eventData: 

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

555 

556 return eventData 

557 

558def getTomorrowsEvents(): 

559 """Grabs each event that occurs tomorrow""" 

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

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

562 return events 

563 

564def addEventView(viewer,event): 

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

566 if not viewer.isCeltsAdmin: 

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

568 

569def getEventRsvpCountsForTerm(term): 

570 """ 

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

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

573 current RSVPs to that event as the pair. 

574 """ 

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

576 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

578 .group_by(Event.id)) 

579 

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

581 

582 return amountAsDict 

583 

584def getEventRsvpCount(eventId): 

585 """ 

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

587 """ 

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

589 

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

591 """ 

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

593 until the start of the event. 

594 

595 Note about dates: 

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

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

598 tomorrow with no mention of the hour.  

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

600 and hours in actual time. 

601 

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

603 relative to this morning and exclude all hours and minutes 

604 

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

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

607 """ 

608 

609 if currentDatetime is None: 

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

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

612 

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

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

615 

616 if eventEnd < currentDatetime: 

617 return "Already passed" 

618 elif eventStart <= currentDatetime <= eventEnd: 

619 return "Happening now" 

620 

621 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

622 calendarDelta = relativedelta(eventStart, currentMorning) 

623 calendarYearsUntilEvent = calendarDelta.years 

624 calendarMonthsUntilEvent = calendarDelta.months 

625 calendarDaysUntilEvent = calendarDelta.days 

626 

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

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

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

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

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

632 

633 # Years until 

634 if calendarYearsUntilEvent: 

635 if calendarMonthsUntilEvent: 

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

637 return f"{yearString}" 

638 # Months until 

639 if calendarMonthsUntilEvent: 

640 if calendarDaysUntilEvent: 

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

642 return f"{monthString}" 

643 # Days until 

644 if calendarDaysUntilEvent: 

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

646 if calendarDaysUntilEvent == 1: 

647 return "Tomorrow" 

648 return f"{dayString}" 

649 if timeUntilEvent.hours: 

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

651 return f"{dayString}" 

652 # Hours until 

653 if timeUntilEvent.hours: 

654 if timeUntilEvent.minutes: 

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

656 return f"{hourString}" 

657 # Minutes until 

658 elif timeUntilEvent.minutes > 1: 

659 return f"{minuteString}" 

660 # Seconds until 

661 return "<1 minute" 

662 

663def copyRsvpToNewEvent(priorEvent, newEvent): 

664 """ 

665 Copies rvsps from priorEvent to newEvent 

666 """ 

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

668 

669 for student in rsvpInfo: 

670 newRsvp = EventRsvp( 

671 user = student.user, 

672 event = newEvent, 

673 rsvpWaitlist = student.rsvpWaitlist 

674 ) 

675 newRsvp.save() 

676 numRsvps = len(rsvpInfo) 

677 if numRsvps: 

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

679 

680 

681def inviteCohortsToEvent(event, cohortYears): 

682 """ 

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

684 """ 

685 invitedCohorts = [] 

686 try: 

687 for year in cohortYears: 

688 year = int(year) 

689 EventCohort.get_or_create( 

690 event=event, 

691 year=year, 

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

693 ) 

694 

695 addBonnerCohortToRsvpLog(year, event.id) 

696 rsvpForBonnerCohort(year, event.id) 

697 invitedCohorts.append(year) 

698 

699 if invitedCohorts: 

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

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

702 

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

704 

705 except Exception as e: 

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

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

708 

709def updateEventCohorts(event, cohortYears): 

710 """ 

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

712 """ 

713 invitedCohorts = [] 

714 try: 

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

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

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

718 

719 for year in yearsToAdd: 

720 EventCohort.get_or_create( 

721 event=event, 

722 year=year, 

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

724 ) 

725 

726 addBonnerCohortToRsvpLog(year, event.id) 

727 rsvpForBonnerCohort(year, event.id) 

728 invitedCohorts.append(year) 

729 

730 if yearsToAdd: 

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

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

733 

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

735 

736 except Exception as e: 

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

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

739 

740