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

352 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2026-09-09 18:24 +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 

123 # Reformat dates from Jul 10, 2026 to 2026-07-10 for easier sorting 

124 eventData2 = [] 

125 for ed in eventData['seriesData']: 

126 try: 

127 ed['eventDate'] = datetime.strptime(ed['eventDate'], "%b %d, %Y").strftime("%Y-%m-%d") 

128 except: 

129 pass # eventDate comes into the system differently for recurring weekly events and recurring events (non-weekly). This should format it correctly for both cases 

130 eventData2.append(ed) 

131 eventData['seriesData'] = eventData2 

132 

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

134 # 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. 

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

136 with mainDB.atomic() as transaction: 

137 for index, event in enumerate(seriesData): 

138 eventInfo = eventData.copy() 

139 eventInfo.update({ 

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

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

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

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

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

145 'seriesId': seriesId, 

146 'isRepeating': bool(isRepeating), 

147 }) 

148 # Try to save each offering 

149 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) 

150 if validationErrorMessage: 

151 failedSavedOfferings.append((index, validationErrorMessage)) 

152 allSavesWereSuccessful = False 

153 else: 

154 savedEvent = savedEvents[0] 

155 savedOfferings.append(savedEvent) 

156 if not allSavesWereSuccessful: 

157 savedOfferings = [] 

158 transaction.rollback() 

159 

160 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings 

161 

162 

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

164 """ 

165 Tries to save an event to the database: 

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

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

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

169 

170 Returns: 

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

172 """ 

173 

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

175 # automatically changed from "" to 0 

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

177 eventData["rsvpLimit"] = None 

178 newEventData = preprocessEventData(eventData) 

179 

180 isValid, validationErrorMessage = validateNewEventData(newEventData) 

181 if not isValid: 

182 return [], validationErrorMessage 

183 

184 events = saveEventToDb(newEventData, renewedEvent) 

185 if attachmentFiles: 

186 for event in events: 

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

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

189 return events, "" 

190 

191 

192def saveEventToDb(newEventData, renewedEvent = False): 

193 

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

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

196 

197 isNewEvent = ('id' not in newEventData) 

198 eventRecords = [] 

199 with mainDB.atomic(): 

200 eventData = { 

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

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

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

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

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

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

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

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

209 "allowsLabor" : newEventData['allowsLabor'], 

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

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

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

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

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

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

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

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

218 } 

219 

220 # These fields below are only relevant during event creation so we only set/change them when  

221 # it is a new event.  

222 if isNewEvent: 

223 eventData['program'] = newEventData['program'] 

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

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

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

227 eventData["isCeltsTraining"] = bool(newEventData.get('isCeltsTraining', False)) 

228 eventRecord = Event.create(**eventData) 

229 else: 

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

231 for key, value in eventData.items(): 

232 setattr(eventRecord, key, value) 

233 eventRecord.save() 

234 

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

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

237 

238 eventRecords.append(eventRecord) 

239 return eventRecords 

240 

241def getVolunteerOpportunities(term): 

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

243 .join(Program) 

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

245 (Event.deletionDate.is_null()) & 

246 (Event.isService == True) & 

247 ((Event.isLaborOnly == False) | Event.isLaborOnly.is_null()) 

248 ) 

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

250 .execute()) 

251 

252 programs = {} 

253 

254 for event in volunteerOpportunities: 

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

256 

257 return programs 

258 

259def getEngagementEvents(term): 

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

261 .join(Program) 

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

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

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

265 .execute()) 

266 return engagementEvents 

267 

268def getUpcomingVolunteerOpportunitiesCount(term, currentDate): 

269 """ 

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

271 """ 

272 

273 upcomingCount = ( 

274 Program 

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

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

277 .where( 

278 (Event.term == term) & 

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

280 (Event.isService == True) & 

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

282 ((Event.startDate > currentDate) | 

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

284 (Event.isCanceled == False) 

285 ) 

286 .group_by(Program.id) 

287 ) 

288 

289 programCountDict = {} 

290 for programCount in upcomingCount: 

291 programCountDict[programCount.id] = programCount.eventCount 

292 return programCountDict 

293 

294def getPastVolunteerOpportunitiesCount(term, currentDate): 

295 """ 

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

297 """ 

298 

299 pastCount = ( 

300 Program 

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

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

303 .where( 

304 (Event.term == term) & 

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

306 (Event.isService == True) & 

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

308 ((Event.startDate < currentDate) | 

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

310 (Event.isCanceled == False) 

311 ) 

312 .group_by(Program.id) 

313 ) 

314 

315 programCountDict = {} 

316 for programCount in pastCount: 

317 programCountDict[programCount.id] = programCount.eventCount 

318 return programCountDict 

319 

320def getTrainingEvents(term, user): 

321 """ 

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

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

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

325 returned is the All Trainings Event. 

326 term: expected to be the ID of a term 

327 user: expected to be the current user 

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

329 """ 

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

331 .join(Program, JOIN.LEFT_OUTER) 

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

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

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

335 

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

337 if hideBonner: 

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

339 

340 return list(trainingQuery.execute()) 

341 

342def getBonnerEvents(term): 

343 bonnerScholarsEvents = list( 

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

345 .join(Program) 

346 .where( 

347 Program.isBonnerScholars, 

348 Event.term == term, 

349 Event.deletionDate == None 

350 ) 

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

352 .execute() 

353 ) 

354 return bonnerScholarsEvents 

355 

356def getCeltsLabor(term): 

357 """ 

358 Labor tab: events explicitly marked as Labor Only. 

359 """ 

360 celtsLabor = list(Event.select() 

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

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

363 .execute()) 

364 return celtsLabor 

365 

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

367 """ 

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

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

370 :param user: a username or User object 

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

372 Used in testing, defaults to the current timestamp. 

373 :return: A list of Event objects 

374 """ 

375 

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

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

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

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

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

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

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

383 

384 if program: 

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

386 

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

388 

389 eventsList = [] 

390 seriesEventsList = [] 

391 

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

393 for event in events: 

394 if event.seriesId: 

395 if not event.isCanceled: 

396 if event.seriesId not in seriesEventsList: 

397 eventsList.append(event) 

398 seriesEventsList.append(event.seriesId) 

399 else: 

400 if not event.isCanceled: 

401 eventsList.append(event) 

402 

403 return eventsList 

404 

405def getParticipatedEventsForUser(user): 

406 """ 

407 Get all the events a user has participated in. 

408 :param user: a username or User object 

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

410 Used in testing, defaults to the current timestamp. 

411 :return: A list of Event objects 

412 """ 

413 

414 eventName = fn.LOWER(Event.name) 

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

416 

417 participatedEvents = (Event.select(Event, 

418 Program.programName, 

419 Case(None, 

420 ( 

421 ((Event.allowsLabor | Event.name.contains("Labor")) & Event.isService, "Labor & Volunteer"), 

422 ((Event.allowsLabor | Event.isLaborOnly | Event.name.contains("Labor")), "Labor"), 

423 (Event.isService, "Volunteer") 

424 ), 

425 "Attendee").alias("participatedType"), 

426 EventParticipant.hoursEarned 

427 ) 

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

429 .join(EventParticipant) 

430 .where(EventParticipant.user == user, 

431 Event.isAllVolunteerTraining == False, Event.deletionDate == None, Event.isCeltsTraining == False) 

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

433 

434 allVolunteer = (Event.select(Event, "", Value("Volunteer").alias("participatedType"), Value(0).alias("hoursEarned")) 

435 .join(EventParticipant) 

436 .where(Event.isAllVolunteerTraining == True, 

437 EventParticipant.user == user)) 

438 union = participatedEvents.union_all(allVolunteer) 

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

440 return unionParticipationWithVolunteer 

441 

442def validateNewEventData(data): 

443 """ 

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

445 

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

447 

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

449 """ 

450 

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

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

453 

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

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

456 

457 # Validation if we are inserting a new event 

458 if 'id' not in data: 

459 

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

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

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

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

464 

465 sameEventListCopy = sameEventList.copy() 

466 

467 for event in sameEventListCopy: 

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

469 sameEventList.remove(event) 

470 

471 try: 

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

473 except DoesNotExist as e: 

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

475 if sameEventList: 

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

477 

478 data['valid'] = True 

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

480 

481def calculateNewSeriesId(): 

482 """ 

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

484 """ 

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

486 if maxSeriesId: 

487 return maxSeriesId + 1 

488 return 1 

489 

490def getPreviousSeriesEventData(seriesId): 

491 """ 

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

493  

494 """ 

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

496 .join(EventParticipant) 

497 .join(Event) 

498 .where(Event.seriesId==seriesId)) 

499 return previousEventVolunteers 

500 

501def getRepeatingEventsData(eventData): 

502 """ 

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

504 dictionary of event data. 

505  

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

507 

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

509 """ 

510 

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

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

513 "week": counter+1, 

514 'location': eventData['location'] 

515 } 

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

517 

518def preprocessEventData(eventData): 

519 """ 

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

521 

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

523 - checkboxes should be True or False 

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

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

526 - seriesData should be a JSON string 

527 - Look up matching certification requirement if necessary 

528 """ 

529 

530 ## Process checkboxes and templateData 

531 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isEngagement', 'isRepeating', 'isAllVolunteerTraining', 'allowsLabor', 'isLaborOnly', 'isCeltsTraining'] 

532 

533 for checkBox in eventCheckBoxes: 

534 if checkBox not in eventData: 

535 eventData[checkBox] = False 

536 else: 

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

538 

539 ## Process dates 

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

541 for eventDate in eventDates: 

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

543 eventData[eventDate] = '' 

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

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

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

547 eventData[eventDate] = '' 

548 

549 # Process seriesData 

550 if 'seriesData' not in eventData: 

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

552 

553 # Process terms 

554 if 'term' in eventData: 

555 try: 

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

557 except DoesNotExist: 

558 eventData['term'] = '' 

559 

560 # Process requirement 

561 if 'certRequirement' in eventData: 

562 try: 

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

564 except DoesNotExist: 

565 eventData['certRequirement'] = '' 

566 elif 'id' in eventData: 

567 # look up requirement 

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

569 if match: 

570 eventData['certRequirement'] = match.requirement 

571 if 'timeStart' in eventData: 

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

573 

574 if 'timeEnd' in eventData: 

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

576 

577 return eventData 

578 

579def getTomorrowsEvents(): 

580 """Grabs each event that occurs tomorrow""" 

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

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

583 return events 

584 

585def addEventView(viewer,event): 

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

587 if not viewer.isCeltsAdmin: 

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

589 

590def getEventRsvpCountsForTerm(term): 

591 """ 

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

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

594 current RSVPs to that event as the pair. 

595 """ 

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

597 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

599 .group_by(Event.id)) 

600 

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

602 

603 return amountAsDict 

604 

605def getEventRsvpCount(eventId): 

606 """ 

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

608 """ 

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

610 

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

612 """ 

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

614 until the start of the event. 

615 

616 Note about dates: 

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

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

619 tomorrow with no mention of the hour.  

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

621 and hours in actual time. 

622 

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

624 relative to this morning and exclude all hours and minutes 

625 

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

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

628 """ 

629 

630 if currentDatetime is None: 

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

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

633 

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

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

636 

637 if eventEnd < currentDatetime: 

638 return "Already passed" 

639 elif eventStart <= currentDatetime <= eventEnd: 

640 return "Happening now" 

641 

642 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

643 calendarDelta = relativedelta(eventStart, currentMorning) 

644 calendarYearsUntilEvent = calendarDelta.years 

645 calendarMonthsUntilEvent = calendarDelta.months 

646 calendarDaysUntilEvent = calendarDelta.days 

647 

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

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

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

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

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

653 

654 # Years until 

655 if calendarYearsUntilEvent: 

656 if calendarMonthsUntilEvent: 

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

658 return f"{yearString}" 

659 # Months until 

660 if calendarMonthsUntilEvent: 

661 if calendarDaysUntilEvent: 

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

663 return f"{monthString}" 

664 # Days until 

665 if calendarDaysUntilEvent: 

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

667 if calendarDaysUntilEvent == 1: 

668 return "Tomorrow" 

669 return f"{dayString}" 

670 if timeUntilEvent.hours: 

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

672 return f"{dayString}" 

673 # Hours until 

674 if timeUntilEvent.hours: 

675 if timeUntilEvent.minutes: 

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

677 return f"{hourString}" 

678 # Minutes until 

679 elif timeUntilEvent.minutes > 1: 

680 return f"{minuteString}" 

681 # Seconds until 

682 return "<1 minute" 

683 

684def copyRsvpToNewEvent(priorEvent, newEvent): 

685 """ 

686 Copies rvsps from priorEvent to newEvent 

687 """ 

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

689 

690 for student in rsvpInfo: 

691 newRsvp = EventRsvp( 

692 user = student.user, 

693 event = newEvent, 

694 rsvpWaitlist = student.rsvpWaitlist 

695 ) 

696 newRsvp.save() 

697 numRsvps = len(rsvpInfo) 

698 if numRsvps: 

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

700 

701 

702def inviteCohortsToEvent(event, cohortYears): 

703 """ 

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

705 """ 

706 invitedCohorts = [] 

707 try: 

708 for year in cohortYears: 

709 year = int(year) 

710 EventCohort.get_or_create( 

711 event=event, 

712 year=year, 

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

714 ) 

715 

716 addBonnerCohortToRsvpLog(year, event.id) 

717 rsvpForBonnerCohort(year, event.id) 

718 invitedCohorts.append(year) 

719 

720 if invitedCohorts: 

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

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

723 

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

725 

726 except Exception as e: 

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

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

729 

730def updateEventCohorts(event, cohortYears): 

731 """ 

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

733 """ 

734 invitedCohorts = [] 

735 try: 

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

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

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

739 

740 for year in yearsToAdd: 

741 EventCohort.get_or_create( 

742 event=event, 

743 year=year, 

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

745 ) 

746 

747 addBonnerCohortToRsvpLog(year, event.id) 

748 rsvpForBonnerCohort(year, event.id) 

749 invitedCohorts.append(year) 

750 

751 if yearsToAdd: 

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

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

754 

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

756 

757 except Exception as e: 

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

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