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

289 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-07-31 16:31 +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 

7from app.models import mainDB 

8from app.models.user import User 

9from app.models.event import Event 

10from app.models.eventParticipant import EventParticipant 

11from app.models.program import Program 

12from app.models.term import Term 

13from app.models.programBan import ProgramBan 

14from app.models.interest import Interest 

15from app.models.eventRsvp import EventRsvp 

16from app.models.requirementMatch import RequirementMatch 

17from app.models.certificationRequirement import CertificationRequirement 

18from app.models.eventViews import EventView 

19 

20from app.logic.createLogs import createActivityLog, createRsvpLog 

21from app.logic.utils import format24HourTime 

22from app.logic.fileHandler import FileHandler 

23from app.logic.certification import updateCertRequirementForEvent 

24 

25def cancelEvent(eventId): 

26 """ 

27 Cancels an event. 

28 """ 

29 event = Event.get_or_none(Event.id == eventId) 

30 if event: 

31 event.isCanceled = True 

32 event.save() 

33 

34 program = event.program 

35 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')}.") 

36 

37 

38def deleteEvent(eventId): 

39 """ 

40 Deletes an event, if it is a recurring event, rename all following events 

41 to make sure there is no gap in weeks. 

42 """ 

43 event = Event.get_or_none(Event.id == eventId) 

44 

45 if event: 

46 if event.recurringId: 

47 recurringId = event.recurringId 

48 recurringEvents = list(Event.select().where(Event.recurringId==recurringId).order_by(Event.id)) # orders for tests 

49 eventDeleted = False 

50 

51 # once the deleted event is detected, change all other names to the previous event's name 

52 for recurringEvent in recurringEvents: 

53 if eventDeleted: 

54 Event.update({Event.name:newEventName}).where(Event.id==recurringEvent.id).execute() 

55 newEventName = recurringEvent.name 

56 

57 if recurringEvent == event: 

58 newEventName = recurringEvent.name 

59 eventDeleted = True 

60 

61 program = event.program 

62 

63 if program: 

64 createActivityLog(f"Deleted \"{event.name}\" for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") 

65 else: 

66 createActivityLog(f"Deleted a non-program event, \"{event.name}\", which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") 

67 

68 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where(Event.id == event.id).execute() 

69 

70def deleteEventAndAllFollowing(eventId): 

71 """ 

72 Deletes a recurring event and all the recurring events after it. 

73 """ 

74 event = Event.get_or_none(Event.id == eventId) 

75 if event: 

76 if event.recurringId: 

77 recurringId = event.recurringId 

78 recurringSeries = list(Event.select(Event.id).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate))) 

79 deletedEventList = [recurringEvent.id for recurringEvent in recurringSeries] 

80 Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate)).execute() 

81 return deletedEventList 

82 

83def deleteAllRecurringEvents(eventId): 

84 """ 

85 Deletes all recurring events. 

86 """ 

87 event = Event.get_or_none(Event.id == eventId) 

88 if event: 

89 if event.recurringId: 

90 recurringId = event.recurringId 

91 allRecurringEvents = list(Event.select(Event.id).where(Event.recurringId == recurringId).order_by(Event.startDate)) 

92 eventId = allRecurringEvents[0].id 

93 return deleteEventAndAllFollowing(eventId) 

94 

95 

96 

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

98 """ 

99 Tries to save an event to the database: 

100 Checks that the event data is valid and if it is it continus to saves the new 

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

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

103 

104 Returns: 

105 Created events and an error message. 

106 """ 

107 

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

109 # automatically changed from "" to 0 

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

111 eventData["rsvpLimit"] = None 

112 newEventData = preprocessEventData(eventData) 

113 

114 isValid, validationErrorMessage = validateNewEventData(newEventData) 

115 

116 if not isValid: 

117 return False, validationErrorMessage 

118 

119 try: 

120 events = saveEventToDb(newEventData, renewedEvent) 

121 if attachmentFiles: 

122 for event in events: 

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

124 addFile.saveFiles(saveOriginalFile=events[0]) 

125 return events, "" 

126 except Exception as e: 

127 print(f'Failed attemptSaveEvent() with Exception: {e}') 

128 return False, e 

129 

130def saveEventToDb(newEventData, renewedEvent = False): 

131 

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

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

134 

135 

136 isNewEvent = ('id' not in newEventData) 

137 

138 

139 eventsToCreate = [] 

140 recurringSeriesId = None 

141 if (isNewEvent and newEventData['isRecurring']) and not renewedEvent: 

142 eventsToCreate = calculateRecurringEventFrequency(newEventData) 

143 recurringSeriesId = calculateNewrecurringId() 

144 else: 

145 eventsToCreate.append({'name': f"{newEventData['name']}", 

146 'date':newEventData['startDate'], 

147 "week":1}) 

148 if renewedEvent: 

149 recurringSeriesId = newEventData.get('recurringId') 

150 eventRecords = [] 

151 for eventInstance in eventsToCreate: 

152 with mainDB.atomic(): 

153 

154 eventData = { 

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

156 "name": eventInstance['name'], 

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

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

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

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

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

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

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

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

165 "startDate": eventInstance['date'], 

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

167 "endDate": eventInstance['date'], 

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

169 "contactName": newEventData['contactName'] 

170 } 

171 

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

173 # it is a new event.  

174 if isNewEvent: 

175 eventData['program'] = newEventData['program'] 

176 eventData['recurringId'] = recurringSeriesId 

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

178 eventRecord = Event.create(**eventData) 

179 else: 

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

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

182 

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

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

185 

186 eventRecords.append(eventRecord) 

187 return eventRecords 

188 

189def getStudentLedEvents(term): 

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

191 .join(Program) 

192 .where(Program.isStudentLed, 

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

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

195 .execute()) 

196 

197 programs = {} 

198 

199 for event in studentLedEvents: 

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

201 

202 return programs 

203 

204def getUpcomingStudentLedCount(term, currentTime): 

205 """ 

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

207 """ 

208 

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

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

211 .where(Program.isStudentLed, 

212 Event.term == term, Event.deletionDate == None, 

213 (Event.endDate > currentTime) | ((Event.endDate == currentTime) & (Event.timeEnd >= currentTime)), 

214 Event.isCanceled == False) 

215 .group_by(Program.id)) 

216 

217 programCountDict = {} 

218 

219 for programCount in upcomingCount: 

220 programCountDict[programCount.id] = programCount.eventCount 

221 return programCountDict 

222 

223def getTrainingEvents(term, user): 

224 """ 

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

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

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

228 returned is the All Trainings Event. 

229 term: expected to be the ID of a term 

230 user: expected to be the current user 

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

232 """ 

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

234 .join(Program, JOIN.LEFT_OUTER) 

235 .where(Event.isTraining == True, 

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

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

238 

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

240 if hideBonner: 

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

242 

243 return list(trainingQuery.execute()) 

244 

245def getBonnerEvents(term): 

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

247 .join(Program) 

248 .where(Program.isBonnerScholars, 

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

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

251 .execute()) 

252 return bonnerScholarsEvents 

253 

254def getOtherEvents(term): 

255 """ 

256  

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

258 the Other Events section of the Events List page. 

259 :return: A list of Other Event objects 

260 """ 

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

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

263 

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

265 .join(Program, JOIN.LEFT_OUTER) 

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

267 Event.isTraining == False, 

268 Event.isAllVolunteerTraining == False, 

269 ((Program.isOtherCeltsSponsored) | 

270 ((Program.isStudentLed == False) & 

271 (Program.isBonnerScholars == False)))) 

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

273 .execute()) 

274 

275 return otherEvents 

276 

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

278 """ 

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

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

281 :param user: a username or User object 

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

283 Used in testing, defaults to the current timestamp. 

284 :return: A list of Event objects 

285 """ 

286 

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

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

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

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

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

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

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

294 

295 if program: 

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

297 

298 events = events.order_by(Event.startDate, Event.name) 

299 

300 events_list = [] 

301 shown_recurring_event_list = [] 

302 

303 # removes all recurring events except for the next upcoming one 

304 for event in events: 

305 if event.recurringId: 

306 if not event.isCanceled: 

307 if event.recurringId not in shown_recurring_event_list: 

308 events_list.append(event) 

309 shown_recurring_event_list.append(event.recurringId) 

310 else: 

311 if not event.isCanceled: 

312 events_list.append(event) 

313 

314 return events_list 

315 

316def getParticipatedEventsForUser(user): 

317 """ 

318 Get all the events a user has participated in. 

319 :param user: a username or User object 

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

321 Used in testing, defaults to the current timestamp. 

322 :return: A list of Event objects 

323 """ 

324 

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

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

327 .join(EventParticipant) 

328 .where(EventParticipant.user == user, 

329 Event.isAllVolunteerTraining == False) 

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

331 

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

333 .join(EventParticipant) 

334 .where(Event.isAllVolunteerTraining == True, 

335 EventParticipant.user == user)) 

336 union = participatedEvents.union_all(allVolunteer) 

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

338 

339 return unionParticipationWithVolunteer 

340 

341def validateNewEventData(data): 

342 """ 

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

344 

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

346 

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

348 """ 

349 

350 if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isService'], data['isRecurring']]: 

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

352 

353 if data['isRecurring'] and data['endDate'] < data['startDate']: 

354 return (False, "Event start date is after event end date.") 

355 

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

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

358 

359 # Validation if we are inserting a new event 

360 if 'id' not in data: 

361 

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

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

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

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

366 

367 sameEventListCopy = sameEventList.copy() 

368 

369 for event in sameEventListCopy: 

370 if event.isCanceled or event.recurringId: 

371 sameEventList.remove(event) 

372 

373 try: 

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

375 except DoesNotExist as e: 

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

377 if sameEventList: 

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

379 

380 data['valid'] = True 

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

382 

383def calculateNewrecurringId(): 

384 """ 

385 Gets the highest recurring Id so that a new recurring Id can be assigned 

386 """ 

387 recurringId = Event.select(fn.MAX(Event.recurringId)).scalar() 

388 if recurringId: 

389 return recurringId + 1 

390 else: 

391 return 1 

392 

393def getPreviousRecurringEventData(recurringId): 

394 """ 

395 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 

396 """ 

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

398 .join(EventParticipant) 

399 .join(Event) 

400 .where(Event.recurringId==recurringId)) 

401 return previousEventVolunteers 

402 

403def calculateRecurringEventFrequency(event): 

404 """ 

405 Calculate the events to create based on a recurring event start and end date. Takes a 

406 dictionary of event data. 

407 

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

409 

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

411 """ 

412 if not isinstance(event['endDate'], date) or not isinstance(event['startDate'], date): 

413 raise Exception("startDate and endDate must be datetime.date objects.") 

414 

415 if event['endDate'] == event['startDate']: 

416 raise Exception("This event is not a recurring event") 

417 return [ {'name': f"{event['name']} Week {counter+1}", 

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

419 "week": counter+1} 

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

421 

422def preprocessEventData(eventData): 

423 """ 

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

425 

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

427 - checkboxes should be True or False 

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

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

430 - Look up matching certification requirement if necessary 

431 """ 

432 ## Process checkboxes 

433 eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isRecurring', 'isAllVolunteerTraining'] 

434 

435 for checkBox in eventCheckBoxes: 

436 if checkBox not in eventData: 

437 eventData[checkBox] = False 

438 else: 

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

440 

441 ## Process dates 

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

443 for eventDate in eventDates: 

444 if eventDate not in eventData: 

445 eventData[eventDate] = '' 

446 elif type(eventData[eventDate]) is str and eventData[eventDate]: 

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

448 elif not isinstance(eventData[eventDate], date): 

449 eventData[eventDate] = '' 

450 

451 # If we aren't recurring, all of our events are single-day 

452 if not eventData['isRecurring']: 

453 eventData['endDate'] = eventData['startDate'] 

454 

455 # Process terms 

456 if 'term' in eventData: 

457 try: 

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

459 except DoesNotExist: 

460 eventData['term'] = '' 

461 

462 # Process requirement 

463 if 'certRequirement' in eventData: 

464 try: 

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

466 except DoesNotExist: 

467 eventData['certRequirement'] = '' 

468 elif 'id' in eventData: 

469 # look up requirement 

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

471 if match: 

472 eventData['certRequirement'] = match.requirement 

473 if 'timeStart' in eventData: 

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

475 

476 if 'timeEnd' in eventData: 

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

478 

479 return eventData 

480 

481def getTomorrowsEvents(): 

482 """Grabs each event that occurs tomorrow""" 

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

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

485 return events 

486 

487def addEventView(viewer,event): 

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

489 if not viewer.isCeltsAdmin: 

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

491 

492def getEventRsvpCountsForTerm(term): 

493 """ 

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

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

496 current RSVPs to that event as the pair. 

497 """ 

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

499 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

501 .group_by(Event.id)) 

502 

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

504 

505 return amountAsDict 

506 

507def getEventRsvpCount(eventId): 

508 """ 

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

510 """ 

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

512 

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

514 """ 

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

516 until the start of the event. 

517 

518 Note about dates: 

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

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

521 tomorrow with no mention of the hour.  

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

523 and hours in actual time. 

524 

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

526 relative to this morning and exclude all hours and minutes 

527 

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

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

530 """ 

531 

532 if currentDatetime is None: 

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

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

535 

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

537 eventEnd = datetime.combine(event.endDate, event.timeEnd) 

538 

539 if eventEnd < currentDatetime: 

540 return "Already passed" 

541 elif eventStart <= currentDatetime <= eventEnd: 

542 return "Happening now" 

543 

544 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

545 calendarDelta = relativedelta(eventStart, currentMorning) 

546 calendarYearsUntilEvent = calendarDelta.years 

547 calendarMonthsUntilEvent = calendarDelta.months 

548 calendarDaysUntilEvent = calendarDelta.days 

549 

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

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

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

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

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

555 

556 # Years until 

557 if calendarYearsUntilEvent: 

558 if calendarMonthsUntilEvent: 

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

560 return f"{yearString}" 

561 # Months until 

562 if calendarMonthsUntilEvent: 

563 if calendarDaysUntilEvent: 

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

565 return f"{monthString}" 

566 # Days until 

567 if calendarDaysUntilEvent: 

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

569 if calendarDaysUntilEvent == 1: 

570 return "Tomorrow" 

571 return f"{dayString}" 

572 if timeUntilEvent.hours: 

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

574 return f"{dayString}" 

575 # Hours until 

576 if timeUntilEvent.hours: 

577 if timeUntilEvent.minutes: 

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

579 return f"{hourString}" 

580 # Minutes until 

581 elif timeUntilEvent.minutes > 1: 

582 return f"{minuteString}" 

583 # Seconds until 

584 return "<1 minute" 

585 

586def copyRsvpToNewEvent(priorEvent, newEvent): 

587 """ 

588 Copies rvsps from priorEvent to newEvent 

589 """ 

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

591 

592 for student in rsvpInfo: 

593 newRsvp = EventRsvp( 

594 user = student.user, 

595 event = newEvent, 

596 rsvpWaitlist = student.rsvpWaitlist 

597 ) 

598 newRsvp.save() 

599 numRsvps = len(rsvpInfo) 

600 if numRsvps: 

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