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

288 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-07-24 12:19 +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 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.delete_instance(recursive = True, delete_nullable = True) 

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().where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate))) 

79 for seriesEvent in recurringSeries: 

80 seriesEvent.delete_instance(recursive = True) 

81 

82def deleteAllRecurringEvents(eventId): 

83 """ 

84 Deletes all recurring events. 

85 """ 

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

87 if event: 

88 if event.recurringId: 

89 recurringId = event.recurringId 

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

91 for aRecurringEvent in allRecurringEvents: 

92 aRecurringEvent.delete_instance(recursive = True) 

93 

94 

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

96 """ 

97 Tries to save an event to the database: 

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

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

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

101 

102 Returns: 

103 Created events and an error message. 

104 """ 

105 

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

107 # automatically changed from "" to 0 

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

109 eventData["rsvpLimit"] = None 

110 newEventData = preprocessEventData(eventData) 

111 

112 isValid, validationErrorMessage = validateNewEventData(newEventData) 

113 

114 if not isValid: 

115 return False, validationErrorMessage 

116 

117 try: 

118 events = saveEventToDb(newEventData, renewedEvent) 

119 if attachmentFiles: 

120 for event in events: 

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

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

123 return events, "" 

124 except Exception as e: 

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

126 return False, e 

127 

128def saveEventToDb(newEventData, renewedEvent = False): 

129 

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

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

132 

133 

134 isNewEvent = ('id' not in newEventData) 

135 

136 

137 eventsToCreate = [] 

138 recurringSeriesId = None 

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

140 eventsToCreate = calculateRecurringEventFrequency(newEventData) 

141 recurringSeriesId = calculateNewrecurringId() 

142 else: 

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

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

145 "week":1}) 

146 if renewedEvent: 

147 recurringSeriesId = newEventData.get('recurringId') 

148 eventRecords = [] 

149 for eventInstance in eventsToCreate: 

150 with mainDB.atomic(): 

151 

152 eventData = { 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

167 "contactName": newEventData['contactName'] 

168 } 

169 

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

171 # it is a new event.  

172 if isNewEvent: 

173 eventData['program'] = newEventData['program'] 

174 eventData['recurringId'] = recurringSeriesId 

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

176 eventRecord = Event.create(**eventData) 

177 else: 

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

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

180 

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

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

183 

184 eventRecords.append(eventRecord) 

185 return eventRecords 

186 

187def getStudentLedEvents(term): 

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

189 .join(Program) 

190 .where(Program.isStudentLed, 

191 Event.term == term) 

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

193 .execute()) 

194 

195 programs = {} 

196 

197 for event in studentLedEvents: 

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

199 

200 return programs 

201 

202def getUpcomingStudentLedCount(term, currentTime): 

203 """ 

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

205 """ 

206 

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

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

209 .where(Program.isStudentLed, 

210 Event.term == term, 

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

212 Event.isCanceled == False) 

213 .group_by(Program.id)) 

214 

215 programCountDict = {} 

216 

217 for programCount in upcomingCount: 

218 programCountDict[programCount.id] = programCount.eventCount 

219 return programCountDict 

220 

221def getTrainingEvents(term, user): 

222 """ 

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

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

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

226 returned is the All Trainings Event. 

227 term: expected to be the ID of a term 

228 user: expected to be the current user 

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

230 """ 

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

232 .join(Program, JOIN.LEFT_OUTER) 

233 .where(Event.isTraining == True, 

234 Event.term == term) 

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

236 

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

238 if hideBonner: 

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

240 

241 return list(trainingQuery.execute()) 

242 

243def getBonnerEvents(term): 

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

245 .join(Program) 

246 .where(Program.isBonnerScholars, 

247 Event.term == term) 

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

249 .execute()) 

250 return bonnerScholarsEvents 

251 

252def getOtherEvents(term): 

253 """ 

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

255 the Other Events section of the Events List page. 

256 :return: A list of Other Event objects 

257 """ 

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

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

260 

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

262 .join(Program, JOIN.LEFT_OUTER) 

263 .where(Event.term == term, 

264 Event.isTraining == False, 

265 Event.isAllVolunteerTraining == False, 

266 ((Program.isOtherCeltsSponsored) | 

267 ((Program.isStudentLed == False) & 

268 (Program.isBonnerScholars == False)))) 

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

270 .execute()) 

271 

272 return otherEvents 

273 

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

275 """ 

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

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

278 :param user: a username or User object 

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

280 Used in testing, defaults to the current timestamp. 

281 :return: A list of Event objects 

282 """ 

283 

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

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

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

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

288 .where(Event.startDate >= asOf, 

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

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

291 

292 if program: 

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

294 

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

296 

297 events_list = [] 

298 shown_recurring_event_list = [] 

299 

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

301 for event in events: 

302 if event.recurringId: 

303 if not event.isCanceled: 

304 if event.recurringId not in shown_recurring_event_list: 

305 events_list.append(event) 

306 shown_recurring_event_list.append(event.recurringId) 

307 else: 

308 if not event.isCanceled: 

309 events_list.append(event) 

310 

311 return events_list 

312 

313def getParticipatedEventsForUser(user): 

314 """ 

315 Get all the events a user has participated in. 

316 :param user: a username or User object 

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

318 Used in testing, defaults to the current timestamp. 

319 :return: A list of Event objects 

320 """ 

321 

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

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

324 .join(EventParticipant) 

325 .where(EventParticipant.user == user, 

326 Event.isAllVolunteerTraining == False) 

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

328 

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

330 .join(EventParticipant) 

331 .where(Event.isAllVolunteerTraining == True, 

332 EventParticipant.user == user)) 

333 union = participatedEvents.union_all(allVolunteer) 

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

335 

336 return unionParticipationWithVolunteer 

337 

338def validateNewEventData(data): 

339 """ 

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

341 

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

343 

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

345 """ 

346 

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

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

349 

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

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

352 

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

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

355 

356 # Validation if we are inserting a new event 

357 if 'id' not in data: 

358 

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

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

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

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

363 

364 sameEventListCopy = sameEventList.copy() 

365 

366 for event in sameEventListCopy: 

367 if event.isCanceled or event.recurringId: 

368 sameEventList.remove(event) 

369 

370 try: 

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

372 except DoesNotExist as e: 

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

374 if sameEventList: 

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

376 

377 data['valid'] = True 

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

379 

380def calculateNewrecurringId(): 

381 """ 

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

383 """ 

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

385 if recurringId: 

386 return recurringId + 1 

387 else: 

388 return 1 

389 

390def getPreviousRecurringEventData(recurringId): 

391 """ 

392 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 

393 """ 

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

395 .join(EventParticipant) 

396 .join(Event) 

397 .where(Event.recurringId==recurringId)) 

398 return previousEventVolunteers 

399 

400def calculateRecurringEventFrequency(event): 

401 """ 

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

403 dictionary of event data. 

404 

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

406 

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

408 """ 

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

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

411 

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

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

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

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

416 "week": counter+1} 

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

418 

419def preprocessEventData(eventData): 

420 """ 

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

422 

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

424 - checkboxes should be True or False 

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

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

427 - Look up matching certification requirement if necessary 

428 """ 

429 ## Process checkboxes 

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

431 

432 for checkBox in eventCheckBoxes: 

433 if checkBox not in eventData: 

434 eventData[checkBox] = False 

435 else: 

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

437 

438 ## Process dates 

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

440 for eventDate in eventDates: 

441 if eventDate not in eventData: 

442 eventData[eventDate] = '' 

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

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

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

446 eventData[eventDate] = '' 

447 

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

449 if not eventData['isRecurring']: 

450 eventData['endDate'] = eventData['startDate'] 

451 

452 # Process terms 

453 if 'term' in eventData: 

454 try: 

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

456 except DoesNotExist: 

457 eventData['term'] = '' 

458 

459 # Process requirement 

460 if 'certRequirement' in eventData: 

461 try: 

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

463 except DoesNotExist: 

464 eventData['certRequirement'] = '' 

465 elif 'id' in eventData: 

466 # look up requirement 

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

468 if match: 

469 eventData['certRequirement'] = match.requirement 

470 if 'timeStart' in eventData: 

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

472 

473 if 'timeEnd' in eventData: 

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

475 

476 return eventData 

477 

478def getTomorrowsEvents(): 

479 """Grabs each event that occurs tomorrow""" 

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

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

482 return events 

483 

484def addEventView(viewer,event): 

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

486 if not viewer.isCeltsAdmin: 

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

488 

489def getEventRsvpCountsForTerm(term): 

490 """ 

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

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

493 current RSVPs to that event as the pair. 

494 """ 

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

496 .join(EventRsvp, JOIN.LEFT_OUTER) 

497 .where(Event.term == term) 

498 .group_by(Event.id)) 

499 

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

501 

502 return amountAsDict 

503 

504def getEventRsvpCount(eventId): 

505 """ 

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

507 """ 

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

509 

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

511 """ 

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

513 until the start of the event. 

514 

515 Note about dates: 

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

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

518 tomorrow with no mention of the hour.  

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

520 and hours in actual time. 

521 

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

523 relative to this morning and exclude all hours and minutes 

524 

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

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

527 """ 

528 

529 if currentDatetime is None: 

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

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

532 

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

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

535 

536 if eventEnd < currentDatetime: 

537 return "Already passed" 

538 elif eventStart <= currentDatetime <= eventEnd: 

539 return "Happening now" 

540 

541 timeUntilEvent = relativedelta(eventStart, currentDatetime) 

542 calendarDelta = relativedelta(eventStart, currentMorning) 

543 calendarYearsUntilEvent = calendarDelta.years 

544 calendarMonthsUntilEvent = calendarDelta.months 

545 calendarDaysUntilEvent = calendarDelta.days 

546 

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

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

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

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

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

552 

553 # Years until 

554 if calendarYearsUntilEvent: 

555 if calendarMonthsUntilEvent: 

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

557 return f"{yearString}" 

558 # Months until 

559 if calendarMonthsUntilEvent: 

560 if calendarDaysUntilEvent: 

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

562 return f"{monthString}" 

563 # Days until 

564 if calendarDaysUntilEvent: 

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

566 if calendarDaysUntilEvent == 1: 

567 return "Tomorrow" 

568 return f"{dayString}" 

569 if timeUntilEvent.hours: 

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

571 return f"{dayString}" 

572 # Hours until 

573 if timeUntilEvent.hours: 

574 if timeUntilEvent.minutes: 

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

576 return f"{hourString}" 

577 # Minutes until 

578 elif timeUntilEvent.minutes > 1: 

579 return f"{minuteString}" 

580 # Seconds until 

581 return "<1 minute" 

582 

583def copyRsvpToNewEvent(priorEvent, newEvent): 

584 """ 

585 Copies rvsps from priorEvent to newEvent 

586 """ 

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

588 

589 for student in rsvpInfo: 

590 newRsvp = EventRsvp( 

591 user = student.user, 

592 event = newEvent, 

593 rsvpWaitlist = student.rsvpWaitlist 

594 ) 

595 newRsvp.save() 

596 numRsvps = len(rsvpInfo) 

597 if numRsvps: 

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