diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..ef39c7a41 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Issue Description + +Fixes #add issue number +- Add issue description + +## Changes + +- Use bullet points to provide a description of added changes. +- Add images, where possible, to provide more context to your changes. + +## Testing + +- Use `backticks` to highlight shell commands or file directories in your test descriptions. +- Use bullet points to provide a concise description of testing procedure. \ No newline at end of file diff --git a/README.md b/README.md index b26f85461..b013ffc69 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ * default python is Python 3 ## Getting Started With CELTS in a devcontainer -1. If on Windows 10, make sure your Windows install is in developer mode so that core.symlinks will be set properly: https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development (requires admin privileges) +1. Windows + * If on Windows 10, make sure your Windows install is in developer mode so that core.symlinks will be set properly: https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development (requires admin privileges) + * In git-bash, set `git config --global core.symlink true` and cross your fingers + * It's possible after the final setup is done in VSCode you will need to fix the symlink in `database` and `app/scripts` 3. Set up an SSH agent with your GitHub SSH key. https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent 4. Open repository directory in VSCode (either clone with VSCode or install git yourself and clone with ```git clone git@github.com:BCStudentSoftwareDevTeam/celts.git```) 5. Follow prompts to install Dev Container extension and open project in dev container @@ -107,4 +110,4 @@ http://ssdt-documentation.berea.edu/ This is a permissions spreadsheet that lists all possible roles a user could have in the application and what permissions they are allowed. If you are adding a new role or feature please update this document: -https://docs.google.com/spreadsheets/d/1RQao6WqHZFZo0rYBPnuwnhvVI856ysqTaY0a5m3IR1Q/edit?usp=sharing \ No newline at end of file +https://docs.google.com/spreadsheets/d/1RQao6WqHZFZo0rYBPnuwnhvVI856ysqTaY0a5m3IR1Q/edit?usp=sharing diff --git a/app/controllers/admin/routes.py b/app/controllers/admin/routes.py index d2a31d4ba..85daa7216 100644 --- a/app/controllers/admin/routes.py +++ b/app/controllers/admin/routes.py @@ -30,7 +30,8 @@ from app.logic.createLogs import createActivityLog from app.logic.certification import getCertRequirements, updateCertRequirements from app.logic.utils import selectSurroundingTerms, getFilesFromRequest, getRedirectTarget, setRedirectTarget -from app.logic.events import cancelEvent, deleteEvent, attemptSaveEvent, preprocessEventData, calculateRecurringEventFrequency, deleteEventAndAllFollowing, deleteAllRecurringEvents, getBonnerEvents,addEventView, getEventRsvpCount, copyRsvpToNewEvent, getCountdownToEvent +from app.logic.events import cancelEvent, deleteEvent, attemptSaveEvent, preprocessEventData, calculateRecurringEventFrequency, deleteEventAndAllFollowing, deleteAllRecurringEvents, getBonnerEvents,addEventView, getEventRsvpCount, copyRsvpToNewEvent, getCountdownToEvent, calculateNewMultipleOfferingId +from app.logic.events import cancelEvent, deleteEvent, attemptSaveEvent, preprocessEventData, calculateRecurringEventFrequency, deleteEventAndAllFollowing, deleteAllRecurringEvents, getBonnerEvents,addEventView, getEventRsvpCount, copyRsvpToNewEvent, getCountdownToEvent, calculateNewMultipleOfferingId from app.logic.participants import getParticipationStatusForTrainings, checkUserRsvp from app.logic.minor import getMinorInterest from app.logic.fileHandler import FileHandler @@ -38,6 +39,22 @@ from app.logic.serviceLearningCourses import parseUploadedFile, saveCourseParticipantsToDatabase, unapprovedCourses, approvedCourses, getImportedCourses, getInstructorCourses, editImportedCourses from app.controllers.admin import admin_bp +from app.logic.spreadsheet import createSpreadsheet + + +@admin_bp.route('/admin/reports') +def reports(): + academicYears = Term.select(Term.academicYear).distinct().order_by(Term.academicYear.desc()) + academicYears = list(map(lambda t: t.academicYear, academicYears)) + return render_template("/admin/reports.html", academicYears=academicYears) + +@admin_bp.route('/admin/reports/download', methods=['POST']) +def downloadFile(): + academicYear = request.form.get('academicYear') + filepath = os.path.abspath(createSpreadsheet(academicYear)) + return send_file(filepath, as_attachment=True) + + @admin_bp.route('/switch_user', methods=['POST']) def switchUser(): @@ -66,6 +83,7 @@ def templateSelect(): @admin_bp.route('/eventTemplates///create', methods=['GET','POST']) def createEvent(templateid, programid): + savedEventsList = [] if not (g.current_user.isAdmin or g.current_user.isProgramManagerFor(programid)): abort(403) @@ -98,13 +116,31 @@ def createEvent(templateid, programid): # Try to save the form if request.method == "POST": eventData.update(request.form.copy()) - try: - savedEvents, validationErrorMessage = attemptSaveEvent(eventData, getFilesFromRequest(request)) + if eventData.get('isMultipleOffering'): + multipleOfferingId = calculateNewMultipleOfferingId() + + multipleOfferingData = json.loads(eventData.get('multipleOfferingData')) + for event in multipleOfferingData: + multipleOfferingDict = eventData.copy() + multipleOfferingDict.update({ + 'name': event['eventName'], + 'startDate': event['eventDate'], + 'timeStart': event['startTime'], + 'timeEnd': event['endTime'], + 'multipleOfferingId': multipleOfferingId + }) + try: + savedEvents, validationErrorMessage = attemptSaveEvent(multipleOfferingDict, getFilesFromRequest(request)) + savedEventsList.append(savedEvents) + + except Exception as e: + print("Failed saving multi event", e) - except Exception as e: - print("Error saving event:", e) - savedEvents = False - validationErrorMessage = "Unknown Error Saving Event. Please try again" + else: + try: + savedEvents, validationErrorMessage = attemptSaveEvent(eventData, getFilesFromRequest(request)) + except Exception as e: + print("Failed saving regular event", e) if savedEvents: rsvpcohorts = request.form.getlist("cohorts[]") @@ -113,14 +149,32 @@ def createEvent(templateid, programid): addBonnerCohortToRsvpLog(int(year), savedEvents[0].id) - noun = (eventData['isRecurring'] == 'on' and "Events" or "Event") # pluralize + noun = ((eventData.get('isRecurring') or eventData.get('isMultipleOffering')) and "Events" or "Event") # pluralize flash(f"{noun} successfully created!", 'success') - + + if program: - if len(savedEvents) > 1: + if len(savedEvents) > 1 and eventData.get('isRecurring'): createActivityLog(f"Created a recurring event, {savedEvents[0].name}, for {program.programName}, with a start date of {datetime.strftime(eventData['startDate'], '%m/%d/%Y')}. The last event in the series will be on {datetime.strftime(savedEvents[-1].startDate, '%m/%d/%Y')}.") + + elif len(savedEventsList) >= 1 and eventData.get('isMultipleOffering'): + modifiedSavedEvents = [item for sublist in savedEventsList for item in sublist] + + event_dates = [event_data[0].startDate.strftime('%m/%d/%Y') for event_data in savedEventsList] + + event_list = ', '.join(f"{event.name}" for event in modifiedSavedEvents) + + if len(modifiedSavedEvents) > 1: + #creates list of events created in a multiple series to display in the logs + event_list = ', '.join(event_list.split(', ')[:-1]) + f', and ' + event_list.split(', ')[-1] + #get last date and stick at the end after 'and' so that it reads like a sentence in admin log + last_event_date = event_dates[-1] + event_dates = ', '.join(event_dates[:-1]) + f', and {last_event_date}' + + createActivityLog(f"Created events {event_list} for {program.programName}, with start dates of {event_dates}.") + else: - createActivityLog(f"Created {savedEvents[0].name} for {program.programName}, with a start date of {datetime.strftime(eventData['startDate'], '%m/%d/%Y')}.") + createActivityLog(f"Created events {savedEvents[0].name} for {program.programName}, with a start date of {datetime.strftime(eventData['startDate'], '%m/%d/%Y')}.") else: createActivityLog(f"Created a non-program event, {savedEvents[0].name}, with a start date of {datetime.strftime(eventData['startDate'], '%m/%d/%Y')}.") @@ -187,7 +241,8 @@ def renewEvent(eventId): 'location': formData['location'], 'startDate': f'{formData["startDate"][-4:]}-{formData["startDate"][0:-5]}', 'endDate': f'{formData["endDate"][-4:]}-{formData["endDate"][0:-5]}', - 'isRecurring': bool(priorEvent['recurringId']) + 'isRecurring': bool(priorEvent['recurringId']), + 'isMultipleOffering': bool(priorEvent['multipleOffeirngId']), }) newEvent, message = attemptSaveEvent(newEventDict, renewedEvent = True) if message: @@ -339,10 +394,33 @@ def cancelRoute(eventId): else: abort(403) +@admin_bp.route('/event/undo', methods=['GET']) +def undoEvent(): + try: + events = session['lastDeletedEvent'] + for eventId in events: + Event.update({Event.deletionDate: None, Event.deletedBy: None}).where(Event.id == eventId).execute() + event = Event.get_or_none(Event.id == eventId) + recurringEvents = list(Event.select().where((Event.recurringId==event.recurringId) & (Event.deletionDate == None)).order_by(Event.id)) + if event.recurringId is not None: + nameCounter = 1 + for recurringEvent in recurringEvents: + newEventNameList = recurringEvent.name.split() + newEventNameList[-1] = f"{nameCounter}" + newEventNameList = " ".join(newEventNameList) + Event.update({Event.name: newEventNameList}).where(Event.id==recurringEvent.id).execute() + nameCounter += 1 + flash("Deletion successfully undone.", "success") + return redirect('/eventsList/' + str(g.current_term)) + except Exception as e: + print('Error while canceling event:', e) + return "", 500 + @admin_bp.route('/event//delete', methods=['POST']) def deleteRoute(eventId): try: deleteEvent(eventId) + session['lastDeletedEvent'] = [eventId] flash("Event successfully deleted.", "success") return redirect(url_for("main.events", selectedTerm=g.current_term)) @@ -353,7 +431,7 @@ def deleteRoute(eventId): @admin_bp.route('/event//deleteEventAndAllFollowing', methods=['POST']) def deleteEventAndAllFollowingRoute(eventId): try: - deleteEventAndAllFollowing(eventId) + session["lastDeletedEvent"] = deleteEventAndAllFollowing(eventId) flash("Events successfully deleted.", "success") return redirect(url_for("main.events", selectedTerm=g.current_term)) @@ -364,7 +442,7 @@ def deleteEventAndAllFollowingRoute(eventId): @admin_bp.route('/event//deleteAllRecurring', methods=['POST']) def deleteAllRecurringEventsRoute(eventId): try: - deleteAllRecurringEvents(eventId) + session["lastDeletedEvent"] = deleteAllRecurringEvents(eventId) flash("Events successfully deleted.", "success") return redirect(url_for("main.events", selectedTerm=g.current_term)) diff --git a/app/controllers/events/routes.py b/app/controllers/events/routes.py index 5645ef023..64d387193 100644 --- a/app/controllers/events/routes.py +++ b/app/controllers/events/routes.py @@ -1,4 +1,4 @@ -from flask import Flask, redirect, flash, url_for, request, render_template, g, json, abort +from flask import Flask, redirect, flash, url_for, request, render_template, g, json, abort, session from datetime import datetime from peewee import DoesNotExist diff --git a/app/controllers/main/routes.py b/app/controllers/main/routes.py index d3bb1917f..f028c99b0 100644 --- a/app/controllers/main/routes.py +++ b/app/controllers/main/routes.py @@ -3,7 +3,7 @@ from peewee import JOIN from http import cookies from playhouse.shortcuts import model_to_dict -from flask import request, render_template, g, abort, flash, redirect, url_for +from flask import request, render_template, g, abort, flash, redirect, url_for, make_response, session from app.controllers.main import main_bp from app import app @@ -93,6 +93,9 @@ def events(selectedTerm, activeTab, programID): managersProgramDict = getManagerProgramDict(g.current_user) + # Fetch toggle state from session + toggle_state = session.get('toggleState', 'unchecked') + return render_template("/events/event_list.html", selectedTerm = term, studentLedEvents = studentLedEvents, @@ -107,11 +110,21 @@ def events(selectedTerm, activeTab, programID): activeTab = activeTab, programID = int(programID), managersProgramDict = managersProgramDict, - countUpcomingStudentLedEvents = countUpcomingStudentLedEvents + countUpcomingStudentLedEvents = countUpcomingStudentLedEvents, + toggle_state = toggle_state ) +@main_bp.route('/updateToggleState', methods=['POST']) +def update_toggle_state(): + toggle_state = request.form.get('toggleState') + + # Update session with toggle state + session['toggleState'] = toggle_state + + return "", 200 + @main_bp.route('/profile/', methods=['GET']) -def viewUsersProfile(username): +def viewUsersProfile(username): """ This function displays the information of a volunteer to the user """ @@ -124,7 +137,7 @@ def viewUsersProfile(username): else: abort(403) # Error 403 if non admin/student-staff user trys to access via url - if (g.current_user == volunteer) or g.current_user.isAdmin: + if (g.current_user == volunteer) or g.current_user.isAdmin: upcomingEvents = getUpcomingEventsForUser(volunteer) participatedEvents = getParticipatedEventsForUser(volunteer) programs = Program.select() @@ -197,7 +210,8 @@ def emergencyContactInfo(username): if not (g.current_user.username == username or g.current_user.isCeltsAdmin): abort(403) - + user = User.get(User.username == username) + if request.method == 'GET': readOnly = g.current_user.username != username contactInfo = EmergencyContact.get_or_none(EmergencyContact.user_id == username) @@ -214,7 +228,7 @@ def emergencyContactInfo(username): rowsUpdated = EmergencyContact.update(**request.form).where(EmergencyContact.user == username).execute() if not rowsUpdated: EmergencyContact.create(user = username, **request.form) - createActivityLog(f"{g.current_user} updated {username}'s emergency contact information.") + createActivityLog(f"{g.current_user.fullName} updated {user.fullName}'s emergency contact information.") flash('Emergency contact information saved successfully!', 'success') if request.args.get('action') == 'exit': @@ -229,6 +243,8 @@ def insuranceInfo(username): """ if not (g.current_user.username == username or g.current_user.isCeltsAdmin): abort(403) + + user = User.get(User.username == username) if request.method == 'GET': readOnly = g.current_user.username != username @@ -247,7 +263,7 @@ def insuranceInfo(username): rowsUpdated = InsuranceInfo.update(**request.form).where(InsuranceInfo.user == username).execute() if not rowsUpdated: InsuranceInfo.create(user = username, **request.form) - createActivityLog(f"{g.current_user} updated {username}'s emergency contact information.") + createActivityLog(f"{g.current_user.fullName} updated {user.fullName}'s insurance information.") flash('Insurance information saved successfully!', 'success') if request.args.get('action') == 'exit': diff --git a/app/controllers/serviceLearning/routes.py b/app/controllers/serviceLearning/routes.py index 45fe8942c..ed921267d 100644 --- a/app/controllers/serviceLearning/routes.py +++ b/app/controllers/serviceLearning/routes.py @@ -13,12 +13,11 @@ from app.models.attachmentUpload import AttachmentUpload from app.logic.utils import selectSurroundingTerms, getFilesFromRequest from app.logic.fileHandler import FileHandler -from app.logic.serviceLearningCourses import getSLProposalInfoForUser, withdrawProposal, renewProposal, updateCourse, createCourse, approvedCourses +from app.logic.serviceLearningCourses import getSLProposalInfoForUser, withdrawProposal, renewProposal, updateCourse, createCourse, approvedCourses, deleteCourseObject from app.logic.downloadFile import * from app.logic.utils import getRedirectTarget, setRedirectTarget from app.controllers.serviceLearning import serviceLearning_bp - @serviceLearning_bp.route('/serviceLearning/courseManagement', methods = ['GET']) @serviceLearning_bp.route('/serviceLearning/courseManagement/', methods = ['GET']) def serviceCourseManagement(username=None): @@ -69,7 +68,7 @@ def slcEditProposal(courseID): filePaths = FileHandler(courseId=course.id).retrievePath(associatedAttachments) terms = selectSurroundingTerms(g.current_term, 0) - + return render_template('serviceLearning/slcNewProposal.html', course = course, questionanswers = questionAnswers, @@ -77,6 +76,7 @@ def slcEditProposal(courseID): statusOfCourse = statusOfCourse, courseInstructor = courseInstructor, filePaths = filePaths, + courseStatus = courseStatus, redirectTarget = getRedirectTarget()) else: @@ -87,9 +87,17 @@ def slcEditProposal(courseID): def slcCreateCourse(): """will give a new course ID so that it can redirect to an edit page""" course = createCourse(g.current_user) - return redirect(url_for('serviceLearning.slcEditProposal', courseID = course.id)) +@serviceLearning_bp.route('/serviceLearning/canceledProposal', methods=['POST']) +def slcCancelProposal(): + courseID = request.form.get('courseID') + course = Course.get_by_id(courseID) + if not course.courseName and not course.courseAbbreviation: + CourseQuestion.delete().where(CourseQuestion.course == course).execute() + course.delete_instance() + return "Proposal Canceled" + @serviceLearning_bp.route('/serviceLearning/exit', methods=['GET']) def slcExitView(): diff --git a/app/logic/events.py b/app/logic/events.py index 0f3a8448a..b85724b95 100644 --- a/app/logic/events.py +++ b/app/logic/events.py @@ -1,4 +1,4 @@ -from flask import url_for +from flask import url_for, g, session from peewee import DoesNotExist, fn, JOIN from dateutil import parser from datetime import timedelta, date, datetime @@ -58,44 +58,49 @@ def deleteEvent(eventId): newEventName = recurringEvent.name eventDeleted = True - program = event.program + program = event.program if program: createActivityLog(f"Deleted \"{event.name}\" for {program.programName}, which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") else: createActivityLog(f"Deleted a non-program event, \"{event.name}\", which had a start date of {datetime.strftime(event.startDate, '%m/%d/%Y')}.") - event.delete_instance(recursive = True, delete_nullable = True) + Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where(Event.id == event.id).execute() + def deleteEventAndAllFollowing(eventId): """ Deletes a recurring event and all the recurring events after it. + Modified to also apply to the case of events with multiple offerings """ event = Event.get_or_none(Event.id == eventId) if event: if event.recurringId: recurringId = event.recurringId - recurringSeries = list(Event.select().where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate))) - for seriesEvent in recurringSeries: - seriesEvent.delete_instance(recursive = True) + recurringSeries = list(Event.select(Event.id).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate))) + deletedEventList = [recurringEvent.id for recurringEvent in recurringSeries] + Event.update({Event.deletionDate: datetime.now(), Event.deletedBy: g.current_user}).where((Event.recurringId == recurringId) & (Event.startDate >= event.startDate)).execute() + return deletedEventList def deleteAllRecurringEvents(eventId): """ Deletes all recurring events. + Modified to also apply for events with multiple offerings """ event = Event.get_or_none(Event.id == eventId) if event: if event.recurringId: recurringId = event.recurringId - allRecurringEvents = list(Event.select().where(Event.recurringId == recurringId)) - for aRecurringEvent in allRecurringEvents: - aRecurringEvent.delete_instance(recursive = True) + allRecurringEvents = list(Event.select(Event.id).where(Event.recurringId == recurringId).order_by(Event.startDate)) + eventId = allRecurringEvents[0].id + return deleteEventAndAllFollowing(eventId) + def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False): """ Tries to save an event to the database: - Checks that the event data is valid and if it is it continus to saves the new + Checks that the event data is valid and if it is, it continues to save the new event to the database and adds files if there are any. If it is not valid it will return a validation error. @@ -107,6 +112,7 @@ def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False): # automatically changed from "" to 0 if eventData["rsvpLimit"] == "": eventData["rsvpLimit"] = None + newEventData = preprocessEventData(eventData) isValid, validationErrorMessage = validateNewEventData(newEventData) @@ -136,9 +142,18 @@ def saveEventToDb(newEventData, renewedEvent = False): eventsToCreate = [] recurringSeriesId = None + multipleSeriesId = None if (isNewEvent and newEventData['isRecurring']) and not renewedEvent: eventsToCreate = calculateRecurringEventFrequency(newEventData) recurringSeriesId = calculateNewrecurringId() + + #temporarily applying the append for single events for now to tests + elif(isNewEvent and newEventData['isMultipleOffering']) and not renewedEvent: + eventsToCreate.append({'name': f"{newEventData['name']}", + 'date':newEventData['startDate'], + "week":1}) + multipleSeriesId = newEventData['multipleOfferingId'] + else: eventsToCreate.append({'name': f"{newEventData['name']}", 'date':newEventData['startDate'], @@ -146,7 +161,7 @@ def saveEventToDb(newEventData, renewedEvent = False): if renewedEvent: recurringSeriesId = newEventData.get('recurringId') eventRecords = [] - for eventInstance in eventsToCreate: + for eventInstance in eventsToCreate: with mainDB.atomic(): eventData = { @@ -172,6 +187,7 @@ def saveEventToDb(newEventData, renewedEvent = False): if isNewEvent: eventData['program'] = newEventData['program'] eventData['recurringId'] = recurringSeriesId + eventData['multipleOfferingId'] = multipleSeriesId eventData["isAllVolunteerTraining"] = newEventData['isAllVolunteerTraining'] eventRecord = Event.create(**eventData) else: @@ -188,7 +204,7 @@ def getStudentLedEvents(term): studentLedEvents = list(Event.select(Event, Program) .join(Program) .where(Program.isStudentLed, - Event.term == term) + Event.term == term, Event.deletionDate == None) .order_by(Event.startDate, Event.timeStart) .execute()) @@ -207,7 +223,7 @@ def getUpcomingStudentLedCount(term, currentTime): upcomingCount = (Program.select(Program.id, fn.COUNT(Event.id).alias("eventCount")) .join(Event, on=(Program.id == Event.program_id)) .where(Program.isStudentLed, - Event.term == term, + Event.term == term, Event.deletionDate == None, (Event.endDate > currentTime) | ((Event.endDate == currentTime) & (Event.timeEnd >= currentTime)), Event.isCanceled == False) .group_by(Program.id)) @@ -231,7 +247,7 @@ def getTrainingEvents(term, user): trainingQuery = (Event.select(Event).distinct() .join(Program, JOIN.LEFT_OUTER) .where(Event.isTraining == True, - Event.term == term) + Event.term == term, Event.deletionDate == None) .order_by(Event.isAllVolunteerTraining.desc(), Event.startDate, Event.timeStart)) hideBonner = (not user.isAdmin) and not (user.isStudent and user.isBonnerScholar) @@ -244,13 +260,14 @@ def getBonnerEvents(term): bonnerScholarsEvents = list(Event.select(Event, Program.id.alias("program_id")) .join(Program) .where(Program.isBonnerScholars, - Event.term == term) + Event.term == term, Event.deletionDate == None) .order_by(Event.startDate, Event.timeStart) .execute()) return bonnerScholarsEvents def getOtherEvents(term): """ + Get the list of the events not caught by other functions to be displayed in the Other Events section of the Events List page. :return: A list of Other Event objects @@ -260,7 +277,7 @@ def getOtherEvents(term): otherEvents = list(Event.select(Event, Program) .join(Program, JOIN.LEFT_OUTER) - .where(Event.term == term, + .where(Event.term == term, Event.deletionDate == None, Event.isTraining == False, Event.isAllVolunteerTraining == False, ((Program.isOtherCeltsSponsored) | @@ -285,25 +302,29 @@ def getUpcomingEventsForUser(user, asOf=datetime.now(), program=None): .join(ProgramBan, JOIN.LEFT_OUTER, on=((ProgramBan.program == Event.program) & (ProgramBan.user == user))) .join(Interest, JOIN.LEFT_OUTER, on=(Event.program == Interest.program)) .join(EventRsvp, JOIN.LEFT_OUTER, on=(Event.id == EventRsvp.event)) - .where(Event.startDate >= asOf, + .where(Event.deletionDate == None, Event.startDate >= asOf, (Interest.user == user) | (EventRsvp.user == user), ProgramBan.user.is_null(True) | (ProgramBan.endDate < asOf))) if program: events = events.where(Event.program == program) - events = events.order_by(Event.startDate, Event.name) + events = events.order_by(Event.startDate, Event.timeStart) events_list = [] shown_recurring_event_list = [] + shown_multiple_offering_event_list = [] # removes all recurring events except for the next upcoming one for event in events: - if event.recurringId: + if event.recurringId or event.multipleOfferingId: if not event.isCanceled: if event.recurringId not in shown_recurring_event_list: events_list.append(event) shown_recurring_event_list.append(event.recurringId) + if event.multipleOfferingId not in shown_multiple_offering_event_list: + events_list.append(event) + shown_multiple_offering_event_list.append(event.multipleOfferingId) else: if not event.isCanceled: events_list.append(event) @@ -344,7 +365,7 @@ def validateNewEventData(data): Returns 3 values: (boolean success, the validation error message, the data object) """ - if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isService'], data['isRecurring']]: + if 'on' in [data['isFoodProvided'], data['isRsvpRequired'], data['isTraining'], data['isService'], data['isRecurring'], data['isMultipleOffering']]: return (False, "Raw form data passed to validate method. Preprocess first.") if data['isRecurring'] and data['endDate'] < data['startDate']: @@ -386,6 +407,15 @@ def calculateNewrecurringId(): return recurringId + 1 else: return 1 +def calculateNewMultipleOfferingId(): + """ + Gets the highest recurring Id so that a new recurring Id can be assigned + """ + multipleOfferingId = Event.select(fn.MAX(Event.multipleOfferingId)).scalar() + if multipleOfferingId: + return multipleOfferingId + 1 + else: + return 1 def getPreviousRecurringEventData(recurringId): """ @@ -397,6 +427,16 @@ def getPreviousRecurringEventData(recurringId): .where(Event.recurringId==recurringId)) return previousEventVolunteers +def getPreviousMultipleOfferingEventData(multipleOfferingId): + """ + 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 + """ + previousEventVolunteers = (User.select(User).distinct() + .join(EventParticipant) + .join(Event) + .where(Event.multipleOfferingId == multipleOfferingId)) + return previousEventVolunteers + def calculateRecurringEventFrequency(event): """ Calculate the events to create based on a recurring event start and end date. Takes a @@ -411,6 +451,7 @@ def calculateRecurringEventFrequency(event): if event['endDate'] == event['startDate']: raise Exception("This event is not a recurring event") + return [ {'name': f"{event['name']} Week {counter+1}", 'date': event['startDate'] + timedelta(days=7*counter), "week": counter+1} @@ -427,7 +468,7 @@ def preprocessEventData(eventData): - Look up matching certification requirement if necessary """ ## Process checkboxes - eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isRecurring', 'isAllVolunteerTraining'] + eventCheckBoxes = ['isFoodProvided', 'isRsvpRequired', 'isService', 'isTraining', 'isRecurring', 'isMultipleOffering', 'isAllVolunteerTraining'] for checkBox in eventCheckBoxes: if checkBox not in eventData: @@ -445,7 +486,7 @@ def preprocessEventData(eventData): elif not isinstance(eventData[eventDate], date): eventData[eventDate] = '' - # If we aren't recurring, all of our events are single-day + # If we aren't recurring, all of our events are single-day or mutliple offerings, which also have the same start and end date if not eventData['isRecurring']: eventData['endDate'] = eventData['startDate'] @@ -494,7 +535,7 @@ def getEventRsvpCountsForTerm(term): """ amount = (Event.select(Event, fn.COUNT(EventRsvp.event_id).alias('count')) .join(EventRsvp, JOIN.LEFT_OUTER) - .where(Event.term == term) + .where(Event.term == term, Event.deletionDate == None) .group_by(Event.id)) amountAsDict = {event.id: event.count for event in amount} @@ -595,4 +636,4 @@ def copyRsvpToNewEvent(priorEvent, newEvent): newRsvp.save() numRsvps = len(rsvpInfo) if numRsvps: - createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}") + createRsvpLog(newEvent, f"Copied {numRsvps} Rsvps from {priorEvent['name']} to {newEvent.name}") \ No newline at end of file diff --git a/app/logic/minor.py b/app/logic/minor.py index a1068442d..9ca4d7e70 100644 --- a/app/logic/minor.py +++ b/app/logic/minor.py @@ -59,6 +59,7 @@ def getMinorProgress(): engagedStudentsList = [{'username': student.username, 'firstName': student.firstName, 'lastName': student.lastName, + 'hasGraduated': student.hasGraduated, 'engagementCount': student.engagementCount - student.hasSummer, 'hasCommunityEngagementRequest': student.hasCommunityEngagementRequest, 'hasSummer': "Completed" if student.hasSummer else "Incomplete"} for student in engagedStudentsWithCount] diff --git a/app/logic/serviceLearningCourses.py b/app/logic/serviceLearningCourses.py index d99cfaad4..e68a124f3 100644 --- a/app/logic/serviceLearningCourses.py +++ b/app/logic/serviceLearningCourses.py @@ -67,8 +67,8 @@ def createCourseDisplayName(name, abbreviation): def saveCourseParticipantsToDatabase(cpPreview: Dict[str, Dict[str, Dict[str, List[Dict[str, Any]]]]]) -> None: for term, terminfo in cpPreview.items(): - termObj: Term = Term.get_or_none(description = term) or addPastTerm(term) - if not termObj: + currentCourseTerm: Term = Term.get_or_none(description = term) or addPastTerm(term) + if not currentCourseTerm: print(f"Unable to find or create term {term}") continue @@ -77,28 +77,67 @@ def saveCourseParticipantsToDatabase(cpPreview: Dict[str, Dict[str, Dict[str, Li print(f"Unable to save course {course}. {courseInfo['errorMsg']}") continue - courseObj: Course = Course.get_or_create( - courseAbbreviation = course, - term = termObj, - defaults = {"CourseName" : "", - "sectionDesignation" : "", - "courseCredit" : "1", - "term" : termObj, - "status" : 4, - "createdBy" : g.current_user, - "serviceLearningDesignatedSections" : "", - "previouslyApprovedDescription" : "" })[0] - - for userDict in courseInfo['students']: - if userDict['errorMsg']: + existingCourses = list((Course.select() + .join(Term, on =(Course.term == Term.id)) + .where((Course.courseAbbreviation == course) & (Term.termOrder <= currentCourseTerm.termOrder)) + .order_by(Course.term.desc()) + .limit(1))) + + if not existingCourses : + + courseObj: Course = Course.create(courseName = "", + sectionDesignation = "", + courseAbbreviation = course, + courseCredit = "1", + term = currentCourseTerm, + status = CourseStatus.IN_PROGRESS, + createdBy = g.current_user, + serviceLearningDesignatedSections = "", + previouslyApprovedDescription = "") + else: + previousMatchedCourse = existingCourses[0] + + previousCourseTerm = Term.get(Term.id == previousMatchedCourse.term) + + if previousCourseTerm == currentCourseTerm: + courseObj : Course = previousMatchedCourse + + else: + + courseObj: Course = Course.create(courseName = previousMatchedCourse.courseName, + courseAbbreviation = previousMatchedCourse.courseAbbreviation, + sectionDesignation = previousMatchedCourse.sectionDesignation, + courseCredit = previousMatchedCourse.courseCredit, + term = currentCourseTerm, + status = CourseStatus.IN_PROGRESS, + createdBy = g.current_user, + serviceLearningDesignatedSections = previousMatchedCourse.serviceLearningDesignatedSections, + previouslyApprovedDescription = previousMatchedCourse.previouslyApprovedDescription) + + questions: List[CourseQuestion] = list(CourseQuestion.select().where(CourseQuestion.course == previousMatchedCourse.id)) + + for question in questions: + CourseQuestion.create(course = courseObj.id, + questionContent= question.questionContent, + questionNumber=question.questionNumber) + + instructors = CourseInstructor.select().where(CourseInstructor.course == previousMatchedCourse.id) + + for instructor in instructors: + CourseInstructor.create(course = courseObj.id, + user = instructor.user) + + + for userDict in courseInfo['students']: + if userDict['errorMsg']: print(f"Unable to save student. {userDict['errorMsg']}") continue - CourseParticipant.get_or_create(user=userDict['user'], + CourseParticipant.get_or_create(user=userDict['user'], course=courseObj, hoursEarned=20) -def unapprovedCourses(termId: int) -> List[Course]: +def unapprovedCourses(termId: int) -> List[Course]:# """ Queries the database to get all the neccessary information for submitted/unapproved courses. @@ -205,7 +244,12 @@ def withdrawProposal(courseID) -> None: except DoesNotExist: print(f"File, {AttachmentUpload.fileName}, does not exist.") - # delete course object + # deletes course + deletedCourse = deleteCourseObject(courseID=courseID) + + createActivityLog(f"Withdrew SLC proposal: {deletedCourse}") + +def deleteCourseObject(courseID): course: Course = Course.get(Course.id == courseID) courseName: str = course.courseName questions: List[CourseQuestion] = CourseQuestion.select().where(CourseQuestion.course == course) @@ -216,8 +260,7 @@ def withdrawProposal(courseID) -> None: course.delete_instance(recursive=True) for note in notes: note.delete_instance() - - createActivityLog(f"Withdrew SLC proposal: {courseName}") + return courseName def createCourse(creator: str="No user provided") -> Course: """ diff --git a/app/logic/spreadsheet.py b/app/logic/spreadsheet.py index ad3f712f1..342843315 100644 --- a/app/logic/spreadsheet.py +++ b/app/logic/spreadsheet.py @@ -13,72 +13,82 @@ def getUniqueVolunteers(academicYear): - uniqueVolunteers = (EventParticipant.select(fn.DISTINCT(EventParticipant.user_id), fn.CONCAT(User.firstName, ' ', User.lastName), User.bnumber) - .join(User).switch(EventParticipant) - .join(Event) - .join(Term) - .where(Term.academicYear == academicYear) - .order_by(EventParticipant.user_id)) - + .join(User).switch(EventParticipant) + .join(Event) + .join(Term) + .where(Term.academicYear == academicYear) + .order_by(EventParticipant.user_id)) + return uniqueVolunteers.tuples() -def getVolunteerProgramEventByTerm(term): -# Volunteers by term for each event the participated in for wich program. user: program, event, term +def getVolunteerProgramEventByTerm(term): volunteersByTerm = (EventParticipant.select(fn.CONCAT(User.firstName, ' ', User.lastName), EventParticipant.user_id, Program.programName, Event.name) - .join(User).switch(EventParticipant) - .join(Event) - .join(Program) - .where(Event.term_id == term) - .order_by(EventParticipant.user_id)) + .join(User).switch(EventParticipant) + .join(Event) + .join(Program) + .where(Event.term_id == term) + .order_by(EventParticipant.user_id)) return volunteersByTerm.tuples() -def totalVolunteerHours(): - - query = (EventParticipant.select(fn.SUM(EventParticipant.hoursEarned))) + +def totalVolunteerHours(academicYear): + query = (EventParticipant.select(fn.SUM(EventParticipant.hoursEarned)) + .join(Event, on=(EventParticipant.event == Event.id)) + .join(Term, on=(Event.term == Term.id)) + .where(Term.academicYear == academicYear) + ) return query.tuples() -def volunteerProgramHours(): +def volunteerProgramHours(academicYear): volunteerProgramHours = (EventParticipant.select(Program.programName, EventParticipant.user_id, fn.SUM(EventParticipant.hoursEarned)) - .join(Event, on=(EventParticipant.event_id == Event.id)) - .join(Program, on=(Event.program_id == Program.id)) - .group_by(Program.programName, EventParticipant.user_id)) + .join(Event, on=(EventParticipant.event_id == Event.id)) + .join(Program, on=(Event.program_id == Program.id)) + .join(Term, on=(Event.term == Term.id)) + .where(Term.academicYear == academicYear) + .group_by(Program.programName, EventParticipant.user_id)) return volunteerProgramHours.tuples() -def onlyCompletedAllVolunteer(academicYear): - # Return volunteers that only attended the All Volunteer Training and then nothing else +def onlyCompletedAllVolunteer(academicYear): subQuery = (EventParticipant.select(EventParticipant.user_id) - .join(Event) - .join(Term) - .where(Event.name != "All Volunteer Training", Term.academicYear == academicYear)) + .join(Event) + .join(Term) + .where(Event.name != "All Volunteer Training", Term.academicYear == academicYear)) onlyAllVolunteer = (EventParticipant.select(EventParticipant.user_id, fn.CONCAT(User.firstName, " ", User.lastName)) - .join(User).switch(EventParticipant) - .join(Event) - .join(Term) - .where(Event.name == "All Volunteer Training", Term.academicYear == academicYear, EventParticipant.user_id.not_in(subQuery))) + .join(User).switch(EventParticipant) + .join(Event) + .join(Term) + .where(Event.name == "All Volunteer Training", Term.academicYear == academicYear, EventParticipant.user_id.not_in(subQuery))) return onlyAllVolunteer.tuples() -def volunteerHoursByProgram(): - query = ((Program.select(Program.programName, fn.SUM(EventParticipant.hoursEarned).alias('sum')).join(Event) - .join(EventParticipant, on=(Event.id == EventParticipant.event_id)) - .group_by(Program.programName) - .order_by(Program.programName))) + +def volunteerHoursByProgram(academicYear): + query = (Program.select(Program.programName, fn.SUM(EventParticipant.hoursEarned).alias('sum')) + .join(Event) + .join(EventParticipant, on=(Event.id == EventParticipant.event_id)) + .join(Term, on=(Term.id == Event.term)) + .where(Term.academicYear == academicYear) + .group_by(Program.programName) + .order_by(Program.programName)) return query.tuples() -def volunteerMajorAndClass(column, reorderClassLevel=False): +def volunteerMajorAndClass(academicYear, column, reorderClassLevel=False): majorAndClass = (User.select(Case(None, ((column.is_null(), "Unknown"),), column), fn.COUNT(fn.DISTINCT(EventParticipant.user_id)).alias('count')) - .join(EventParticipant, on=(User.username == EventParticipant.user_id)) - .group_by(column)) + .join(EventParticipant, on=(User.username == EventParticipant.user_id)) + .join(Event, on=(EventParticipant.event_id == Event.id)) + .join(Term, on=(Event.term == Term.id)) + .where(Term.academicYear == academicYear) + .group_by(column)) if reorderClassLevel: majorAndClass = majorAndClass.order_by(Case(None, ((column == "Freshman", 1), @@ -88,55 +98,61 @@ def volunteerMajorAndClass(column, reorderClassLevel=False): (column == "Graduating", 5), (column == "Non-Degree", 6), (column.is_null(), 7)), - 8)) - else: - majorAndClass = majorAndClass.order_by(column.asc(nulls = 'LAST')) - + 8)) + else: + majorAndClass = majorAndClass.order_by(column.asc(nulls='LAST')) + return majorAndClass.tuples() -def repeatVolunteersPerProgram(): - # Get people who participated in events more than once (individual program) - repeatPerProgramQuery = (EventParticipant.select(fn.CONCAT(User.firstName, " ", User.lastName).alias('fullName'), - Program.programName.alias("programName"), + +def repeatVolunteersPerProgram(academicYear): + repeatPerProgramQuery = (EventParticipant.select(fn.CONCAT(User.firstName, " ", User.lastName).alias('fullName'), + Program.programName.alias("programName"), fn.COUNT(EventParticipant.event_id).alias('event_count')) - .join(Event, on=(EventParticipant.event_id == Event.id)) - .join(Program, on=(Event.program == Program.id)) - .join(User, on=(User.username == EventParticipant.user_id)) - .group_by(User.firstName, User.lastName, Event.program) - .having(fn.COUNT(EventParticipant.event_id) > 1) - .order_by(Event.program, User.lastName)) - + .join(Event, on=(EventParticipant.event_id == Event.id)) + .join(Program, on=(Event.program == Program.id)) + .join(User, on=(User.username == EventParticipant.user_id)) + .join(Term, on=(Event.term == Term.id)) + .where(Term.academicYear == academicYear) + .group_by(User.firstName, User.lastName, Event.program) + .having(fn.COUNT(EventParticipant.event_id) > 1) + .order_by(Event.program, User.lastName)) + return repeatPerProgramQuery.tuples() -def repeatVolunteers(): - # Get people who participated in events more than once (all programs) - repeatAllProgramQuery = (EventParticipant.select(fn.CONCAT(User.firstName," ", User.lastName), fn.COUNT(EventParticipant.user_id).alias('count')) - .join(User, on=(User.username == EventParticipant.user_id)) - .group_by(User.firstName, User.lastName) - .having(fn.COUNT(EventParticipant.user_id) > 1)) - + +def repeatVolunteers(academicYear): + repeatAllProgramQuery = (EventParticipant.select(fn.CONCAT(User.firstName, " ", User.lastName), fn.COUNT(EventParticipant.user_id).alias('count')) + .join(User, on=(User.username == EventParticipant.user_id)) + .join(Event, on=(EventParticipant.event == Event.id)) + .join(Term, on=(Event.term == Term.id)) + .where(Term.academicYear == academicYear) + .group_by(User.firstName, User.lastName) + .having(fn.COUNT(EventParticipant.user_id) > 1)) + return repeatAllProgramQuery.tuples() + def getRetentionRate(academicYear): - # Returns a list of tuples of program retention information in the format ('program name', 'percent people retained') retentionList = [] fall, spring = academicYear.split("-") fallParticipationDict = termParticipation(f"Fall {fall}") - springParticipationDict = termParticipation(f"Spring {spring}") + springParticipationDict = termParticipation(f"Spring {spring}") - # calculate the retention rate using the defined function retentionRateDict = calculateRetentionRate(fallParticipationDict, springParticipationDict) for program, retentionRate in retentionRateDict.items(): - retentionList.append((program, str(round(retentionRate * 100, 2)) + "%")) + retentionList.append((program, str(round(retentionRate * 100, 2)) + "%")) return retentionList + def termParticipation(termDescription): participationQuery = (Event.select(Event.program, EventParticipant.user_id.alias('participant'), Program.programName.alias("programName")) - .join(EventParticipant, JOIN.LEFT_OUTER, on=(Event.id == EventParticipant.event)) - .join(Program, on=(Program.id == Event.program)) - .join(Term, on=(Event.term_id == Term.id)) - .where(Term.description == termDescription)) + .join(EventParticipant, JOIN.LEFT_OUTER, on=(Event.id == EventParticipant.event)) + .join(Program, on=(Event.program == Program.id)) + .join(Term, on=(Event.term_id == Term.id)) + .where(Term.description == termDescription) + .order_by(EventParticipant.user)) programParticipationDict = defaultdict(list) for result in participationQuery.dicts(): @@ -146,109 +162,27 @@ def termParticipation(termDescription): return dict(programParticipationDict) + def removeNullParticipants(participantList): - # loop through the list and remove all entries that do not have a participant return list(filter(lambda participant: participant, participantList)) - -# function to calculate the retention rate for each program + + def calculateRetentionRate(fallDict, springDict): retentionDict = {} for program in fallDict: fallParticipants = set(removeNullParticipants(fallDict[program])) springParticipants = set(removeNullParticipants(springDict.get(program, []))) retentionRate = 0.0 - try: + try: retentionRate = len(fallParticipants & springParticipants) / len(fallParticipants) except ZeroDivisionError: pass retentionDict[program] = retentionRate - return retentionDict -# def halfRetentionRateRecurringEvents(): - -# programs = ProgramEvent.select(ProgramEvent.program_id).distinct() - -# retention_rates = {} - -# # Loop over the programs and get the corresponding event IDs -# for program in programs: -# # Define the query for each program -# query = (EventParticipant.select(EventParticipant.event_id.alias("event_id"), Event.name.alias("name")) -# .join(Event, on=(EventParticipant.event_id == Event.id)) -# .join(ProgramEvent, on=(EventParticipant.event_id == ProgramEvent.event_id)) -# .join(Program, on=(Program.id == ProgramEvent.program_id)) -# .where((ProgramEvent.program_id == program.program_id) & (Event.recurringId != None)) -# .distinct() -# .dicts()) - -# event_count = 0 -# name_counts = defaultdict(int) - -# for result in query: -# event_count += 1 -# participants = EventParticipant.select(EventParticipant.user_id).where(EventParticipant.event_id == result["event_id"]) -# for participant in participants: -# name = participant.user_id -# name_counts[name] += 1 - -# half_count = event_count // 2 -# qualified_names = [name for name, count in name_counts.items() if count >= half_count] - -# if len(name_counts) > 0: -# percentage = len(qualified_names) / len(name_counts) * 100 -# else: -# percentage = 0 - -# retention_rates[program.program.programName] = percentage - -# return retention_rates - - -# def fullRetentionRateRecurringEvents(): - -# programs = ProgramEvent.select(ProgramEvent.program_id).distinct() - -# full_retention = {} - -# # Loop over the programs and get the corresponding event IDs -# for program in programs: -# # Define the query for each program -# query = (EventParticipant.select(EventParticipant.event_id.alias("event_id"), Event.name.alias("name")) -# .join(Event, on=(EventParticipant.event_id == Event.id)) -# .join(ProgramEvent, on=(EventParticipant.event_id == ProgramEvent.event_id)) -# .join(Program, on=(Program.id == ProgramEvent.program_id)) -# .where((ProgramEvent.program_id == program.program_id) & (Event.recurringId != None)) -# .distinct() -# .dicts()) - -# event_count = 0 -# name_counts = defaultdict(int) - -# for result in query: -# event_count += 1 -# participants = EventParticipant.select(EventParticipant.user_id).where(EventParticipant.event_id == result["event_id"]) -# for participant in participants: -# name = participant.user_id -# name_counts[name] += 1 - -# qualified_names = [name for name, count in name_counts.items() if count >= event_count] - -# if len(name_counts) > 0: -# percentage = len(qualified_names) / len(name_counts) * 100 -# else: -# percentage = 0 - -# full_retention[program.program.programName] = percentage - -# return full_retention - -# create a new Excel file - -# define function to save data to a sheet in the Excel file -def makeDataXls(getData, columnTitles, sheetName, workbook): +def makeDataXls(getData, columnTitles, sheetName, workbook): worksheet = workbook.add_worksheet(sheetName) bold = workbook.add_format({'bold': True}) @@ -259,15 +193,16 @@ def makeDataXls(getData, columnTitles, sheetName, workbook): for column, rowData in enumerate(getData): for data, value in enumerate(rowData): - worksheet.write(column+2, data, value) - + worksheet.write(column + 2, data, value) + for column, title in enumerate(columnTitles): columnData = [title] + [rowData[column] for rowData in getData] setColumnWidth = max(len(str(x)) for x in columnData) worksheet.set_column(column, column, setColumnWidth + 3) -def createSpreadsheet(academicYear): - filepath = app.config['files']['base_path'] + '/volunteer_data.xlsx' + +def createSpreadsheet(academicYear): + filepath = f"{app.config['files']['base_path']}/volunteer_data_{academicYear}.xlsx" workbook = xlsxwriter.Workbook(filepath, {'in_memory': True}) hoursByProgramColumns = ["Program", "Hours"] @@ -278,23 +213,22 @@ def createSpreadsheet(academicYear): volunteerProgramRetentionRateAcrossTermColumns = ["Program", "Retention Rate"] uniqueVolunteersColumns = ["Username", "Full Name", "B-Number"] totalVolunteerHoursColumns = ["Total Volunteer Hours"] - volunteerProgramHoursColumns = [ "Program Name", "Volunteer Username", "Volunteer Hours"] + volunteerProgramHoursColumns = ["Program Name", "Volunteer Username", "Volunteer Hours"] onlyCompletedAllVolunteerColumns = ["Username", "Full Name"] volunteerProgramEventByTerm = ["Full Name", "Username", "Program Name", "Event Name"] - - makeDataXls(volunteerHoursByProgram(), hoursByProgramColumns, "Total Hours By Program", workbook) - makeDataXls(volunteerMajorAndClass(User.major), volunteerMajorColumns, "Volunteers By Major", workbook) - makeDataXls(volunteerMajorAndClass(User.classLevel, reorderClassLevel=True), volunteerClassColumns, "Volunteers By Class Level", workbook) - makeDataXls(repeatVolunteersPerProgram(), repeatProgramEventVolunteerColumns, "Repeat Volunteers Per Program", workbook) - makeDataXls(repeatVolunteers(), repeatAllProgramVolunteerColumns, "Repeat Volunteers All Programs", workbook) + makeDataXls(volunteerHoursByProgram(academicYear), hoursByProgramColumns, "Total Hours By Program", workbook) + makeDataXls(volunteerMajorAndClass(academicYear, User.major), volunteerMajorColumns, "Volunteers By Major", workbook) + makeDataXls(volunteerMajorAndClass(academicYear, User.classLevel, reorderClassLevel=True), volunteerClassColumns, "Volunteers By Class Level", workbook) + makeDataXls(repeatVolunteersPerProgram(academicYear), repeatProgramEventVolunteerColumns, "Repeat Volunteers Per Program", workbook) + makeDataXls(repeatVolunteers(academicYear), repeatAllProgramVolunteerColumns, "Repeat Volunteers All Programs", workbook) makeDataXls(getRetentionRate(academicYear), volunteerProgramRetentionRateAcrossTermColumns, "Retention Rate By Semester", workbook) makeDataXls(getUniqueVolunteers(academicYear), uniqueVolunteersColumns, "Unique Volunteers", workbook) - makeDataXls(totalVolunteerHours(), totalVolunteerHoursColumns, "Total Hours", workbook) - makeDataXls(volunteerProgramHours(), volunteerProgramHoursColumns, "Volunteer Hours By Program", workbook) - makeDataXls(onlyCompletedAllVolunteer(academicYear), onlyCompletedAllVolunteerColumns , "Only All Volunteer Training", workbook) + makeDataXls(totalVolunteerHours(academicYear), totalVolunteerHoursColumns, "Total Hours", workbook) + makeDataXls(volunteerProgramHours(academicYear), volunteerProgramHoursColumns, "Volunteer Hours By Program", workbook) + makeDataXls(onlyCompletedAllVolunteer(academicYear), onlyCompletedAllVolunteerColumns, "Only All Volunteer Training", workbook) makeDataXls(getVolunteerProgramEventByTerm(Term.get_or_none(Term.description == f"Fall {academicYear.split('-')[0]}")), volunteerProgramEventByTerm, f"Fall {academicYear.split('-')[0]}", workbook) - + workbook.close() return filepath diff --git a/app/models/course.py b/app/models/course.py index 2ce93095f..a744ef9ec 100644 --- a/app/models/course.py +++ b/app/models/course.py @@ -1,7 +1,6 @@ from app.models import * from app.models.term import Term from app.models.courseStatus import CourseStatus -from app.models.note import Note from app.models.user import User class Course(baseModel): @@ -18,5 +17,4 @@ class Course(baseModel): isAllSectionsServiceLearning = BooleanField(default=False) isRegularlyOccurring = BooleanField(default=False) isPreviouslyApproved = BooleanField(default=False) - hasSlcComponent = BooleanField(default=False) - + hasSlcComponent = BooleanField(default=False) \ No newline at end of file diff --git a/app/models/courseStatus.py b/app/models/courseStatus.py index d73b85152..126114c5e 100644 --- a/app/models/courseStatus.py +++ b/app/models/courseStatus.py @@ -1,5 +1,6 @@ from app.models import* + class CourseStatus(baseModel): status = CharField() IN_PROGRESS = 1 diff --git a/app/models/event.py b/app/models/event.py index 13070fd0b..f354eaf4a 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -19,16 +19,23 @@ class Event(baseModel): startDate = DateField() endDate = DateField(null=True) recurringId = IntegerField(null=True) + multipleOfferingId = IntegerField(null=True) contactEmail = CharField(null=True) contactName = CharField(null=True) program = ForeignKeyField(Program) isCanceled = BooleanField(default=False) + deletionDate = DateTimeField(null=True) + deletedBy = TextField(null=True) _spCache = "Empty" def __str__(self): return f"{self.id}: {self.description}" + @property + def isDeleted(self): + return self.deletionDate is not None + @property def noProgram(self): return not self.program_id @@ -50,6 +57,10 @@ def isFirstRecurringEvent(self): firstRecurringEvent = Event.select().where(Event.recurringId==self.recurringId).order_by(Event.id).get() return firstRecurringEvent.id == self.id + @property + def isMultipleOffering(self): + return bool(self.multipleOfferingId) + @property def relativeTime(self): relativeTime = datetime.combine(self.startDate, self.timeStart) - datetime.now() @@ -69,4 +80,4 @@ def relativeTime(self): else: return f"happening now" - + \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index ba8c19ee3..f42d683b2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -18,6 +18,7 @@ class User(baseModel): isCeltsStudentStaff = BooleanField(default = False) dietRestriction = TextField(null=True) minorInterest = BooleanField(default=False) + hasGraduated = BooleanField(default=False) # override BaseModel's __init__ so that we can set up an instance attribute for cache def __init__(self,*args, **kwargs): diff --git a/app/static/css/base.css b/app/static/css/base.css index cd4d3e3ca..f8992eaac 100644 --- a/app/static/css/base.css +++ b/app/static/css/base.css @@ -53,7 +53,7 @@ select.empty { text-align: center !important; height: 80px; line-height: 80px; /* Vertically center the text */ - position:relative; + position: relative; z-index:10 } @@ -110,4 +110,17 @@ select.empty { font-weight: bolder; /* make it bold */ vertical-align: middle; /* Align vertically with the text */ line-height: 1; /* Match the line height of the parent text */ +} + +form { + margin-bottom: 5em; +} + +.saveBtn { + z-index: 10; + text-align: center; +} + +.saveBtnDiv { + text-align: center; } \ No newline at end of file diff --git a/app/static/css/createEvent.css b/app/static/css/createEvent.css new file mode 100644 index 000000000..638498de2 --- /dev/null +++ b/app/static/css/createEvent.css @@ -0,0 +1,57 @@ +.toggleHeader{ + padding-bottom: 10px; + font-weight: bold; + text-align: center; + margin-left: 50%; /* Position the header at 50% of the viewport width */ + transform: translateX(-50%); + width: 170px +} +.columnDivide{ + flex: 1; + border-left: 2px solid grey; + margin-left: 10px; + margin-right: -30px; +}.addMultipleBtn{ + border-radius: 5px; + margin-left: auto; + margin-right: auto; +} + +.deleteRow{ + border-radius: 5px; + position:relative; + top: 12px; +} +.invalidFeedback { + display: none; + color: #dc3545; + font-size: 0.875rem; + border: 1px solid darkred; + padding: 0.75rem 1.25rem; + margin-bottom: 10px; + border-radius: 0.25rem; + background-color: #f8d7da; + margin-top: 25px; + left: 50%; /* Position the header at 50% of the viewport width */ + transform: translateX(-50%); + text-align: center; + position: fixed; + z-index: 10; + animation: flash 6s forwards; +} + +@keyframes flash { + 0%, 89.9% { + opacity: 1; + } + 90%, 100% { + opacity: 0; + } +} +.border-red { + border: 2px solid red; +} +.divStripes { + background-color: #fff + } + diff --git a/app/static/css/userProfile.css b/app/static/css/userProfile.css index ac474dcf7..cd5414a3b 100644 --- a/app/static/css/userProfile.css +++ b/app/static/css/userProfile.css @@ -2,10 +2,14 @@ vertical-align: middle; } -.ongoing-event{ +.past-event{ opacity:0.6; } +.ongoing-event{ + background-color: #cdebaa; +} + dt { font-weight: normal; padding: 5px; diff --git a/app/static/js/base.js b/app/static/js/base.js index 88981f5db..51ba133bf 100644 --- a/app/static/js/base.js +++ b/app/static/js/base.js @@ -1,16 +1,29 @@ +const flashMessageResponse = function flashEventResponse(message){ + if (message.slice(-8) == "deleted."){ + + return `Undo` + } + return ''; +} + function msgFlash(flash_message, status){ if (!["success", "warning", "info", "danger"].includes(status)) status = "danger"; $("#flash_container").prepend(` -