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

294 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2025-01-29 15:39 +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 

20 

21from app.logic.createLogs import createActivityLog, createRsvpLog 

22from app.logic.utils import format24HourTime 

23from app.logic.fileHandler import FileHandler 

24from app.logic.certification import updateCertRequirementForEvent 

25 

26def cancelEvent(eventId): 

27 """ 

28 Cancels an event. 

29 """ 

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

31 if event: 

32 event.isCanceled = True 

33 event.save() 

34 

35 program = event.program 

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

37 

38#NEEDS FIXING: process not working properly for repeating events when two events are deleted consecutively 

39def deleteEvent(eventId): 

40 """ 

41 Deletes an event, if it is a repeating event, rename all following events 

42 to make sure there is no gap in weeks. 

43  

44 """ 

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

46 

47 if event: 

48 if event.isRepeating: 

49 seriesId = event.seriesId 

50 repeatingEvents = list(Event.select().where(Event.seriesId==seriesId).order_by(Event.id)) # orders for tests 

51 eventDeleted = False 

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

53 for repeatingEvent in repeatingEvents: 

54 if eventDeleted: 

55 Event.update({Event.name:newEventName}).where(Event.id==repeatingEvent.id).execute() 

56 newEventName = repeatingEvent.name 

57 

58 if repeatingEvent == event: 

59 newEventName = repeatingEvent.name 

60 eventDeleted = True 

61 

62 program = event.program 

63 

64 if program: 

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

66 else: 

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

68 

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

70 

71def deleteEventAndAllFollowing(eventId): 

72 """ 

73 Deletes an event in the series and all events after it 

74  

75 """ 

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

77 if event: 

78 if event.seriesId: 

79 seriesId = event.seriesId 

80 eventSeries = list(Event.select(Event.id).where((Event.seriesId == seriesId) & (Event.startDate >= event.startDate))) 

81 deletedEventList = [event.id for event in eventSeries] 

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

83 return deletedEventList 

84 

85def deleteAllEventsInSeries(eventId): 

86 """ 

87 Deletes all events in a series by getting the first event in the series and calling deleteEventAndAllFollowing(). 

88  

89 """ 

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

91 if event: 

92 if event.seriesId: 

93 seriesId = event.seriesId 

94 allSeriesEvents = list(Event.select(Event.id).where(Event.seriesId == seriesId).order_by(Event.startDate)) 

95 eventId = allSeriesEvents[0].id 

96 return deleteEventAndAllFollowing(eventId) 

97 else: 

98 raise ValueError(f"Event with id {event.id} does not belong to a series (seriesId is None).") 

99 

100def attemptSaveMultipleOfferings(eventData, attachmentFiles = None): 

101 """ 

102 Tries to save an event with multiple offerings to the database: 

103 Creates separate event data inheriting from the original eventData 

104 with the specifics of each offering. 

105 Calls attemptSaveEvent on each of the newly created datum 

106 If any data is not valid it will return a validation error. 

107 

108 Returns: 

109 allSavesWereSuccessful : bool | Whether or not all offering saves were successful 

110 savedOfferings : List[event] | A list of event objects holding all offerings that were saved. If allSavesWereSuccessful is False then this list will be empty. 

111 failedSavedOfferings : List[(int, str), ...] | Tuples containing the indicies of failed saved offerings and the associated validation error message.  

112 """ 

113 savedOfferings = [] 

114 failedSavedOfferings = [] 

115 allSavesWereSuccessful = True 

116 

117 seriesId = calculateNewSeriesId() 

118 

119 # Create separate event data inheriting from the original eventData 

120 seriesData = eventData.get('seriesData') 

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

122 with mainDB.atomic() as transaction: 

123 for index, event in enumerate(seriesData): 

124 eventInfo = eventData.copy() 

125 eventInfo.update({ 

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

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

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

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

130 'seriesId': seriesId, 

131 'isRepeating': bool(isRepeating) 

132 }) 

133 # Try to save each offering 

134 savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) 

135 if validationErrorMessage: 

136 failedSavedOfferings.append((index, validationErrorMessage)) 

137 allSavesWereSuccessful = False 

138 else: 

139 savedEvent = savedEvents[0] 

140 savedOfferings.append(savedEvent) 

141 if not allSavesWereSuccessful: 

142 savedOfferings = [] 

143 transaction.rollback() 

144 

145 return allSavesWereSuccessful, savedOfferings, failedSavedOfferings 

146 

147 

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

149 """ 

150 Tries to save an event to the database: 

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

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

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

154 

155 Returns: 

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

157 """ 

158 

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

160 # automatically changed from "" to 0 

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

162 eventData["rsvpLimit"] = None 

163 

164 newEventData = preprocessEventData(eventData) 

165 

166 isValid, validationErrorMessage = validateNewEventData(newEventData) 

167 if not isValid: 

168 return [], validationErrorMessage 

169 

170 events = saveEventToDb(newEventData, renewedEvent) 

171 if attachmentFiles: 

172 for event in events: 

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

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

175 return events, "" 

176 

177 

178def saveEventToDb(newEventData, renewedEvent = False): 

179 

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

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

182 

183 isNewEvent = ('id' not in newEventData) 

184 

185 eventRecords = [] 

186 with mainDB.atomic(): 

187 

188 eventData = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

202 "endDate": newEventData['startDate'], 

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

204 "contactName": newEventData['contactName'] 

205 } 

206 

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

208 # it is a new event.  

209 if isNewEvent: 

210 eventData['program'] = newEventData['program'] 

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

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

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

214 eventRecord = Event.create(**eventData) 

215 else: 

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

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

218 

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

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

221 

222 eventRecords.append(eventRecord) 

223 return eventRecords 

224 

225def getStudentLedEvents(term): 

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

227 .join(Program) 

228 .where(Program.isStudentLed, 

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

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

231 .execute()) 

232 

233 programs = {} 

234 

235 for event in studentLedEvents: 

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

237 

238 return programs 

239 

240def getUpcomingStudentLedCount(term, currentTime): 

241 """ 

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

243 """ 

244 

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

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

247 .where(Program.isStudentLed, 

248 Event.term == term, Event.deletionDate == None, 

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

250 Event.isCanceled == False) 

251 .group_by(Program.id)) 

252 

253 programCountDict = {} 

254 

255 for programCount in upcomingCount: 

256 programCountDict[programCount.id] = programCount.eventCount 

257 return programCountDict 

258 

259def getTrainingEvents(term, user): 

260 """ 

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

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

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

264 returned is the All Trainings Event. 

265 term: expected to be the ID of a term 

266 user: expected to be the current user 

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

268 """ 

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

270 .join(Program, JOIN.LEFT_OUTER) 

271 .where(Event.isTraining == True, 

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

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

274 

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

276 if hideBonner: 

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

278 

279 return list(trainingQuery.execute()) 

280 

281def getBonnerEvents(term): 

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

283 .join(Program) 

284 .where(Program.isBonnerScholars, 

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

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

287 .execute()) 

288 return bonnerScholarsEvents 

289 

290def getOtherEvents(term): 

291 """ 

292  

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

294 the Other Events section of the Events List page. 

295 :return: A list of Other Event objects 

296 """ 

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

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

299 

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

301 .join(Program, JOIN.LEFT_OUTER) 

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

303 Event.isTraining == False, 

304 Event.isAllVolunteerTraining == False, 

305 ((Program.isOtherCeltsSponsored) | 

306 ((Program.isStudentLed == False) & 

307 (Program.isBonnerScholars == False)))) 

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

309 .execute()) 

310 

311 return otherEvents 

312 

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

314 """ 

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

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

317 :param user: a username or User object 

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

319 Used in testing, defaults to the current timestamp. 

320 :return: A list of Event objects 

321 """ 

322 

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

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

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

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

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

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

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

330 

331 if program: 

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

333 

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

335 

336 eventsList = [] 

337 seriesEventsList = [] 

338 

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

340 for event in events: 

341 if event.seriesId: 

342 if not event.isCanceled: 

343 if event.seriesId not in seriesEventsList: 

344 eventsList.append(event) 

345 seriesEventsList.append(event.seriesId) 

346 else: 

347 if not event.isCanceled: 

348 eventsList.append(event) 

349 

350 return eventsList 

351 

352def getParticipatedEventsForUser(user): 

353 """ 

354 Get all the events a user has participated in. 

355 :param user: a username or User object 

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

357 Used in testing, defaults to the current timestamp. 

358 :return: A list of Event objects 

359 """ 

360 

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

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

363 .join(EventParticipant) 

364 .where(EventParticipant.user == user, 

365 Event.isAllVolunteerTraining == False) 

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

367 

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

369 .join(EventParticipant) 

370 .where(Event.isAllVolunteerTraining == True, 

371 EventParticipant.user == user)) 

372 union = participatedEvents.union_all(allVolunteer) 

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

374 

375 return unionParticipationWithVolunteer 

376 

377def validateNewEventData(data): 

378 """ 

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

380 

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

382 

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

384 """ 

385 

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

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

388 

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

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

391 

392 # Validation if we are inserting a new event 

393 if 'id' not in data: 

394 

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

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

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

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

399 

400 sameEventListCopy = sameEventList.copy() 

401 

402 for event in sameEventListCopy: 

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

404 sameEventList.remove(event) 

405 

406 try: 

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

408 except DoesNotExist as e: 

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

410 if sameEventList: 

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

412 

413 data['valid'] = True 

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

415 

416def calculateNewSeriesId(): 

417 """ 

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

419 """ 

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

421 if maxSeriesId: 

422 return maxSeriesId + 1 

423 return 1 

424 

425def getPreviousSeriesEventData(seriesId): 

426 """ 

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

428  

429 """ 

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

431 .join(EventParticipant) 

432 .join(Event) 

433 .where(Event.seriesId==seriesId)) 

434 return previousEventVolunteers 

435 

436def getRepeatingEventsData(eventData): 

437 """ 

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

439 dictionary of event data. 

440  

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

442 

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

444 """ 

445 

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

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

448 "week": counter+1} 

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

450 

451def preprocessEventData(eventData): 

452 """ 

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

454 

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

456 - checkboxes should be True or False 

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

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

459 - seriesData should be a JSON string 

460 - Look up matching certification requirement if necessary 

461 """ 

462 ## Process checkboxes 

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

464 

465 for checkBox in eventCheckBoxes: 

466 if checkBox not in eventData: 

467 eventData[checkBox] = False 

468 else: 

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

470 

471 ## Process dates 

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

473 for eventDate in eventDates: 

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

475 eventData[eventDate] = '' 

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

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

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

479 eventData[eventDate] = '' 

480 

481 # Process seriesData 

482 if 'seriesData' not in eventData: 

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

484 

485 # Process terms 

486 if 'term' in eventData: 

487 try: 

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

489 except DoesNotExist: 

490 eventData['term'] = '' 

491 

492 # Process requirement 

493 if 'certRequirement' in eventData: 

494 try: 

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

496 except DoesNotExist: 

497 eventData['certRequirement'] = '' 

498 elif 'id' in eventData: 

499 # look up requirement 

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

501 if match: 

502 eventData['certRequirement'] = match.requirement 

503 if 'timeStart' in eventData: 

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

505 

506 if 'timeEnd' in eventData: 

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

508 

509 return eventData 

510 

511def getTomorrowsEvents(): 

512 """Grabs each event that occurs tomorrow""" 

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

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

515 return events 

516 

517def addEventView(viewer,event): 

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

519 if not viewer.isCeltsAdmin: 

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

521 

522def getEventRsvpCountsForTerm(term): 

523 """ 

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

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

526 current RSVPs to that event as the pair. 

527 """ 

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

529 .join(EventRsvp, JOIN.LEFT_OUTER) 

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

531 .group_by(Event.id)) 

532 

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

534 

535 return amountAsDict 

536 

537def getEventRsvpCount(eventId): 

538 """ 

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

540 """ 

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

542 

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

544 """ 

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

546 until the start of the event. 

547 

548 Note about dates: 

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

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

551 tomorrow with no mention of the hour.  

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

553 and hours in actual time. 

554 

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

556 relative to this morning and exclude all hours and minutes 

557 

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

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

560 """ 

561 

562 if currentDatetime is None: 

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

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

565 

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

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

568 

569 if eventEnd < currentDatetime: 

570 return "Already passed" 

571 elif eventStart <= currentDatetime <= eventEnd: 

572 return "Happening now" 

573 

574 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

575 calendarDelta = relativedelta(eventStart, currentMorning) 

576 calendarYearsUntilEvent = calendarDelta.years 

577 calendarMonthsUntilEvent = calendarDelta.months 

578 calendarDaysUntilEvent = calendarDelta.days 

579 

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

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

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

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

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

585 

586 # Years until 

587 if calendarYearsUntilEvent: 

588 if calendarMonthsUntilEvent: 

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

590 return f"{yearString}" 

591 # Months until 

592 if calendarMonthsUntilEvent: 

593 if calendarDaysUntilEvent: 

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

595 return f"{monthString}" 

596 # Days until 

597 if calendarDaysUntilEvent: 

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

599 if calendarDaysUntilEvent == 1: 

600 return "Tomorrow" 

601 return f"{dayString}" 

602 if timeUntilEvent.hours: 

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

604 return f"{dayString}" 

605 # Hours until 

606 if timeUntilEvent.hours: 

607 if timeUntilEvent.minutes: 

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

609 return f"{hourString}" 

610 # Minutes until 

611 elif timeUntilEvent.minutes > 1: 

612 return f"{minuteString}" 

613 # Seconds until 

614 return "<1 minute" 

615 

616def copyRsvpToNewEvent(priorEvent, newEvent): 

617 """ 

618 Copies rvsps from priorEvent to newEvent 

619 """ 

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

621 

622 for student in rsvpInfo: 

623 newRsvp = EventRsvp( 

624 user = student.user, 

625 event = newEvent, 

626 rsvpWaitlist = student.rsvpWaitlist 

627 ) 

628 newRsvp.save() 

629 numRsvps = len(rsvpInfo) 

630 if numRsvps: 

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