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

288 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-07-29 13:28 +0000

1from flask import url_for 

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 :param eventId : Integer used to find specific event to be canceled 

28 

29 """ 

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

31 

32 if event: 

33 event.isCanceled = True 

34 event.save() 

35 

36 program = event.program 

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

38 

39 

40def deleteEvent(eventId): 

41 """ 

42 :param eventId : Integer used to find specific event to be deleted. 

43 

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

45 to make sure there is no gap in weeks. 

46 """ 

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

48 

49 if event: 

50 #Builds list of recurring events in a series 

51 if event.recurringId: 

52 recurringId = event.recurringId 

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

54 eventDeleted = False 

55 

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

57 for recurringEvent in recurringEvents: 

58 if eventDeleted: 

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

60 newEventName = recurringEvent.name 

61 

62 if recurringEvent == event: 

63 newEventName = recurringEvent.name 

64 eventDeleted = True 

65 

66 program = event.program 

67 

68 if program: 

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

70 else: 

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

72 

73 event.delete_instance(recursive = True, delete_nullable = True) 

74 

75def deleteEventAndAllFollowing(eventId): 

76 """ 

77 :param eventId : Integer used to find the first event and following events to be deleted. 

78 

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

80 """ 

81 

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

83 if event: 

84 #Makes a list of all instances of the recurring event and events AFTER its start date. 

85 if event.recurringId: 

86 recurringId = event.recurringId 

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

88 # Loops through list, deletes matching events in database. 

89 for seriesEvent in recurringSeries: 

90 seriesEvent.delete_instance(recursive = True) 

91 

92def deleteAllRecurringEvents(eventId): 

93 """ 

94 :param eventId : Integer used to find all recurring events in a series to be deleted. 

95  

96 Deletes all recurring events in a series. 

97 """ 

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

99 if event: 

100 if event.recurringId: 

101 #Makes of list of all recurring events in a series 

102 recurringId = event.recurringId 

103 allRecurringEvents = list(Event.select().where(Event.recurringId == recurringId)) 

104 #Deletes the events from the data base 

105 for aRecurringEvent in allRecurringEvents: 

106 aRecurringEvent.delete_instance(recursive = True) 

107 

108 

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

110 """ 

111 :param eventData: Dictionary that holds data related to the event. 

112 :param attachmentFiles: files to be attached to event. 

113 :param bool renewedEvent: Checks if the event has passed and is being reused. 

114  

115 Tries to save an event to the database: 

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

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

118 

119 :returns: Created events OR an error message. 

120 """ 

121 

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

123 eventData["rsvpLimit"] = None 

124 #preprocess data to match data with proper values 

125 newEventData = preprocessEventData(eventData) 

126 

127 isValid, validationErrorMessage = validateNewEventData(newEventData) 

128 

129 if not isValid: 

130 return False, validationErrorMessage 

131 try: 

132 events = saveEventToDb(newEventData, renewedEvent) 

133 if attachmentFiles: 

134 for event in events: 

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

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

137 return events, "" 

138 except Exception as e: 

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

140 return False, e 

141 

142def saveEventToDb(newEventData, renewedEvent = False): 

143 """ 

144 :param dict newEventData: Dictionary containing event info (name, dates etc) 

145 :param bool renewedEvent: Deterimines if the event is being renewed from an existing event 

146 

147 Takes in a preprocessed dictionary (newEventData) 

148 and adds it to the data base, checking to see it is 

149 reoccuring, and if its a new event. 

150 

151 :return eventRecords: dictionary containing event data 

152 """ 

153 

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

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

156 

157 isNewEvent = ('id' not in newEventData) 

158 

159 eventsToCreate = [] 

160 recurringSeriesId = None 

161 

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

163 

164 eventsToCreate = calculateRecurringEventFrequency(newEventData) 

165 

166 recurringSeriesId = calculateNewrecurringId() 

167 else: 

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

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

170 "week":1}) 

171 if renewedEvent: 

172 recurringSeriesId = newEventData.get('recurringId') 

173 eventRecords = [] 

174 

175 #loops based on how many times an event happens 

176 #Executes once if not recurring  

177 

178 for eventInstance in eventsToCreate: 

179 with mainDB.atomic(): 

180 

181 eventData = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

196 "contactName": newEventData['contactName'] 

197 } 

198 

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

200 # it is a new event.  

201 if isNewEvent: 

202 eventData['program'] = newEventData['program'] 

203 eventData['recurringId'] = recurringSeriesId 

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

205 #copy of eventData dictionary containing updated program, recurringId, and volunteer check 

206 eventRecord = Event.create(**eventData) 

207 else: 

208 #for accessing event to be editted (this is if it already exists) 

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

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

211 

212 #If event requires certification, add that certification to eventRecord 

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

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

215 

216 eventRecords.append(eventRecord) 

217 return eventRecords 

218 

219def getStudentLedEvents(term): 

220 """ 

221 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

222 as an integer, 1 being the oldest term in DB. 

223 

224 :returns programs: dictionary containing all student led Events for given term 

225 and which programs they belong to. 

226 """ 

227 #lists program IDs with their corresponding events saved in the DB sorted by date and time 

228 #student led programs included Berea Buddies(program 2) and adopt a grandparent (program 3) 

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

230 .join(Program) 

231 .where(Program.isStudentLed, 

232 Event.term == term) 

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

234 .execute()) 

235 

236 programs = {} 

237 

238 #loops through list of student led events and adds them to their proper program categories 

239 for event in studentLedEvents: 

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

241 return programs 

242 

243def getUpcomingStudentLedCount(term, currentTime): 

244 """ 

245 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

246 as an integer ID, 1 being the oldest term in DB. 

247 

248 :param currentTime: Takes in the current time 

249 

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

251 

252 :return: programCountDict, a list of key value pairs 

253 key = programID, value = number of events for that key(Program) in a given term 

254 """ 

255 #Gets all student led events in DB whose start time is >= current time(Get) 

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

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

258 .where(Program.isStudentLed, 

259 Event.term == term, 

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

261 Event.isCanceled == False) 

262 .group_by(Program.id)) 

263 

264 programCountDict = {} 

265 

266 for programCount in upcomingCount: 

267 programCountDict[programCount.id] = programCount.eventCount 

268 return programCountDict 

269 

270def getTrainingEvents(term, user): 

271 """ 

272 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

273 as an integer ID, 1 being the oldest term in DB. 

274 

275 :param user: Expected to be the current user  

276 

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

278 together by events of similiar type.  

279  

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

281 """ 

282 #Get all the train events for term in question 

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

284 .join(Program, JOIN.LEFT_OUTER) 

285 .where(Event.isTraining == True, 

286 Event.term == term) 

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

288 #Hide Bonner Scholar sect. if user IS NOT ADMIN OR BONNER SCHOLAR 

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

290 if hideBonner: 

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

292 

293 return list(trainingQuery.execute()) 

294 

295def getBonnerEvents(term): 

296 """ 

297 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

298 as an integer ID, 1 being the oldest term in DB. 

299 

300 :returns bonnerScholarsEvents: dictionary containing all Bonner related events for given term 

301 """ 

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

303 .join(Program) 

304 .where(Program.isBonnerScholars, 

305 Event.term == term) 

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

307 .execute()) 

308 return bonnerScholarsEvents 

309 

310def getOtherEvents(term): 

311 """ 

312 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

313 as an integer ID, 1 being the oldest term in DB. 

314 

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

316 the Other Events section of the Events List page. 

317 

318 :return: A list of Other Event objects 

319 """ 

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

321 .join(Program, JOIN.LEFT_OUTER) 

322 .where(Event.term == term, 

323 Event.isTraining == False, 

324 Event.isAllVolunteerTraining == False, 

325 ((Program.isOtherCeltsSponsored) | 

326 ((Program.isStudentLed == False) & 

327 (Program.isBonnerScholars == False)))) 

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

329 .execute()) 

330 

331 return otherEvents 

332 

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

334 """ 

335 :param user: Expected to be the current user  

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

337 Used in testing, defaults to the current timestamp.  

338 :param program: What type of program the event is. 

339 

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

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

342 

343 :return: A list of Event objects 

344 """ 

345 #Get its all events from DB that user is not banned from 

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

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

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

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

350 .where(Event.startDate >= asOf, 

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

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

353 #set events to programs that the user is interested in, then ordered by closest start date. 

354 if program: 

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

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

357 

358 events_list = [] 

359 shown_recurring_event_list = [] 

360 

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

362 for event in events: 

363 if event.recurringId: 

364 if not event.isCanceled: 

365 if event.recurringId not in shown_recurring_event_list: 

366 events_list.append(event) 

367 shown_recurring_event_list.append(event.recurringId) 

368 else: 

369 if not event.isCanceled: 

370 events_list.append(event) 

371 

372 return events_list 

373 

374def getParticipatedEventsForUser(user): 

375 """ 

376 :param user: Expected to be the current user.  

377  

378 :return: A list of Event objects 

379 """ 

380 #Get all events that the user has participated in 

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

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

383 .join(EventParticipant) 

384 .where(EventParticipant.user == user, 

385 Event.isAllVolunteerTraining == False) 

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

387 #Get all volunteer events the user has taken part in 

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

389 .join(EventParticipant) 

390 .where(Event.isAllVolunteerTraining == True, 

391 EventParticipant.user == user)) 

392 union = participatedEvents.union_all(allVolunteer) 

393 #list of events separated by ID and whether they are volunteer events or not. 

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

395 return unionParticipationWithVolunteer 

396 

397def validateNewEventData(data): 

398 """ 

399 :param data: Dictionary containing information pertaining to an event 

400 

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

402 

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

404 

405 :return: Boolean success, the validation error message. 

406 """ 

407 

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

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

410 

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

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

413 

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

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

416 

417 # Validation if we are inserting a new event 

418 if 'id' not in data: 

419 

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

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

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

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

424 

425 sameEventListCopy = sameEventList.copy() 

426 

427 #removes canceled/recurring events from sameEventList 

428 for event in sameEventListCopy: 

429 if event.isCanceled or event.recurringId: 

430 sameEventList.remove(event) 

431 

432 try: 

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

434 except DoesNotExist as e: 

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

436 if sameEventList: 

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

438 

439 data['valid'] = True 

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

441 

442def calculateNewrecurringId(): 

443 """ 

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

445 if the event is recurring 

446 

447 :return: integer based on the amount of events in a recurring series 

448 (used to ID recurring events) 

449 """ 

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

451 if recurringId: 

452 return recurringId + 1 

453 else: 

454 return 1 

455 

456def getPreviousRecurringEventData(recurringId): 

457 """ 

458 :param recurringId: Id for a specific recurring event series. 

459 

460 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 

461 

462 :return previousEventVolunteers: key value pairs containing users that have participated in past events  

463 within a same recurring series 

464 """ 

465 

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

467 .join(EventParticipant) 

468 .join(Event) 

469 .where(Event.recurringId==recurringId)) 

470 return previousEventVolunteers 

471 

472def calculateRecurringEventFrequency(event): 

473 """ 

474 :param event: Event object in question. 

475 

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

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

478 

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

480 """ 

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

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

483 

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

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

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

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

488 "week": counter+1} 

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

490 

491def preprocessEventData(eventData): 

492 """ 

493 :param eventData: Data of an event from the database. 

494 

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

496 

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

498 - checkboxes should be True or False 

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

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

501 - Look up matching certification requirement if necessary 

502 

503 :return eventData: Dictionary of data for the event after being processed 

504 """ 

505 ## Process checkboxes 

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

507 

508 for checkBox in eventCheckBoxes: 

509 if checkBox not in eventData: 

510 eventData[checkBox] = False 

511 else: 

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

513 

514 ## Process dates 

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

516 for eventDate in eventDates: 

517 if eventDate not in eventData: 

518 eventData[eventDate] = '' 

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

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

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

522 eventData[eventDate] = '' 

523 

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

525 if not eventData['isRecurring']: 

526 eventData['endDate'] = eventData['startDate'] 

527 

528 # Process terms 

529 if 'term' in eventData: 

530 try: 

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

532 except DoesNotExist: 

533 eventData['term'] = '' 

534 

535 # Process requirement 

536 if 'certRequirement' in eventData: 

537 try: 

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

539 except DoesNotExist: 

540 eventData['certRequirement'] = '' 

541 elif 'id' in eventData: 

542 # look up requirement 

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

544 if match: 

545 eventData['certRequirement'] = match.requirement 

546 #changes event start and end times to matach a 24hr format 

547 if 'timeStart' in eventData: 

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

549 

550 if 'timeEnd' in eventData: 

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

552 

553 return eventData 

554 

555def getTomorrowsEvents(): 

556 """ 

557 :return events: List of event objects that occur tomorrow. 

558 """ 

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

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

561 return events 

562 

563def addEventView(viewer,event): 

564 """ 

565 :param viewer: User that is looking at specified event 

566 

567 :param event: Event object in question. 

568 

569 This checks if the current user already viewed the event. If not, insert a record to EventView table. 

570 """ 

571 if not viewer.isCeltsAdmin: 

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

573 

574def getEventRsvpCountsForTerm(term): 

575 """ 

576 :param term: Object that gives a range of time (fall 2024, spring 2024..) 

577 as an integer ID, 1 being the oldest term in DB. 

578  

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

580 

581 :return amountAsDict: dictionary with the event id as the key and the amount of 

582 current RSVPs to that event as the pair. 

583 """ 

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

585 .join(EventRsvp, JOIN.LEFT_OUTER) 

586 .where(Event.term == term) 

587 .group_by(Event.id)) 

588 

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

590 

591 return amountAsDict 

592 

593def getEventRsvpCount(eventId): 

594 """ 

595 :param eventId : Integer used to track specific event. 

596 

597 :return: the number of RSVP'd participants for a given eventId. 

598 """ 

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

600 

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

602 """ 

603 :param event: Event object in question. 

604 

605 :param *: Must specify currentDatetime in parameters, when called 

606 this would look like: getCountdownToEvent(event, currentDatetime=currentTime) 

607 

608 :param currentDatetime: Current time 

609 

610 Note about dates: 

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

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

613 tomorrow with no mention of the hour.  

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

615 and hours in actual time. 

616 

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

618 relative to this morning and exclude all hours and minutes 

619 

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

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

622 

623 :return: A string that conveys the amount of time left until the start of the event. 

624 """ 

625 

626 #If currentDatetime is None, get current time 

627 if currentDatetime is None: 

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

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

630 #formats how times look into variables 

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

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

633 

634 if eventEnd < currentDatetime: 

635 return "Already passed" 

636 elif eventStart <= currentDatetime <= eventEnd: 

637 return "Happening now" 

638 

639 #assgins remaining days (years, months, days) and remaining time until the event starts 

640 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

641 calendarDelta = relativedelta(eventStart, currentMorning) 

642 calendarYearsUntilEvent = calendarDelta.years 

643 calendarMonthsUntilEvent = calendarDelta.months 

644 calendarDaysUntilEvent = calendarDelta.days 

645 

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

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

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

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

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

651 

652 # Years until 

653 if calendarYearsUntilEvent: 

654 if calendarMonthsUntilEvent: 

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

656 return f"{yearString}" 

657 # Months until 

658 if calendarMonthsUntilEvent: 

659 if calendarDaysUntilEvent: 

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

661 return f"{monthString}" 

662 # Days until 

663 if calendarDaysUntilEvent: 

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

665 if calendarDaysUntilEvent == 1: 

666 return "Tomorrow" 

667 return f"{dayString}" 

668 if timeUntilEvent.hours: 

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

670 return f"{dayString}" 

671 # Hours until 

672 if timeUntilEvent.hours: 

673 if timeUntilEvent.minutes: 

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

675 return f"{hourString}" 

676 # Minutes until 

677 elif timeUntilEvent.minutes > 1: 

678 return f"{minuteString}" 

679 # Seconds until 

680 return "<1 minute" 

681 

682def copyRsvpToNewEvent(priorEvent, newEvent): 

683 """ 

684 :param priorEvent: Dictionary data from a prior event 

685 

686 :param newEvent: Dictionary data from a new event 

687 

688 Copies rvsps from priorEvent to newEvent and saves it to the DB 

689 """ 

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

691 

692 for student in rsvpInfo: 

693 newRsvp = EventRsvp( 

694 user = student.user, 

695 event = newEvent, 

696 rsvpWaitlist = student.rsvpWaitlist 

697 ) 

698 newRsvp.save() 

699 numRsvps = len(rsvpInfo) 

700 if numRsvps: 

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