diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d47696d51..a5dba9a5d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:0-3.11 +FROM mcr.microsoft.com/devcontainers/python:3.13 ENV PYTHONUNBUFFERED 1 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index e767a9714..987d9bae4 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -18,6 +18,7 @@ services: db: image: mysql restart: always + command: --require_secure_transport=OFF environment: MYSQL_DATABASE: 'celts' MYSQL_USER: 'celts_user' @@ -31,4 +32,4 @@ services: - celts_data:/var/lib/mysql volumes: - celts_data: \ No newline at end of file + celts_data: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ef39c7a41..df3b41ae8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Issue Description -Fixes #add issue number +Fixes issue # - Add issue description ## Changes diff --git a/.github/workflows/actionTests.yml b/.github/workflows/actionTests.yml index 58f86d1ef..4d61214c9 100644 --- a/.github/workflows/actionTests.yml +++ b/.github/workflows/actionTests.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write strategy: matrix: - python-version: ["3.10", "3.11", "3.12","3.13"] + python-version: ["3.10", "3.11", "3.12","3.13","3.14"] # each step can define `env` vars, but it's easiest to define them on the build level env: diff --git a/README.md b/README.md index b013ffc69..ba66d2168 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Flask application to manage CELTS programs ## Requirements - * Python 3.7+ + * Python 3.10+ **Packages (Ubuntu)** * python3-dev diff --git a/app/config/default.yml b/app/config/default.yml index e4b9b14e2..b480cc7d2 100644 --- a/app/config/default.yml +++ b/app/config/default.yml @@ -181,3 +181,29 @@ contributors: role: "Software Engineer" - name: "Alina Petrosyants" role: "Software Engineer" + - name: "Denys Zhytkov" + role: "Software Engineer" + - name: "Bhushan Sah" + role: "Software Engineer" + - name: "Julius Fritz" + role: "Software Engineer" + - name: "Artem Kurasov" + role: "Software Engineer" + - name: "Daniel Rukwasha" + role: "Software Engineer" + - name: "Bright A Feitsop" + role: "Software Engineer" + - name: "Alpha Nyabuto" + role: "Software Engineer" + - name: "John Lolonga" + role: "Software Engineer" + - name: "Dingani Munsaka" + role: "Software Engineer" + - name: "Andrew Carnes" + role: "Software Engineer" + - name: "Chris Georgiev" + role: "Software Engineer" + - name: "Nahom Mesfin Terrefe" + role: "Software Engineer" + - name: "Dayton Conwell" + role: "Software Engineer" diff --git a/app/controllers/admin/minor.py b/app/controllers/admin/minor.py index 291f303e9..de6e1f58b 100644 --- a/app/controllers/admin/minor.py +++ b/app/controllers/admin/minor.py @@ -4,7 +4,7 @@ from app.controllers.admin import admin_bp -from app.logic.minor import getMinorInterest, getMinorProgress, toggleMinorInterest, getMinorSpreadsheet, getDeclaredMinorStudents +from app.logic.minor import getDeclaredMinorStudents, getMinorInterest, getMinorProgress, toggleMinorInterest, getMinorSpreadsheet @admin_bp.route('/admin/cceMinor', methods=['GET','POST']) def manageMinor(): @@ -20,20 +20,19 @@ def manageMinor(): return redirect(url_for("admin.manageMinor")) - - interestedStudentsList = getMinorInterest() interestedStudentEmailString = ';'.join([student['email'] for student in interestedStudentsList]) - sustainedEngagement = getMinorProgress() - declaredStudentsList = getDeclaredMinorStudents() - declaredStudentEmailString = ';'.join([student['email'] for student in declaredStudentsList]) + declaredStudentsDict = getDeclaredMinorStudents() + declaredStudentEmailString = ';'.join([student['email'] for student in declaredStudentsDict]) + + cceMinorStudents = declaredStudentsDict + return render_template('/admin/cceMinor.html', + cceMinorStudents = cceMinorStudents, interestedStudentsList = interestedStudentsList, - declaredStudentsList = declaredStudentsList, interestedStudentEmailString = interestedStudentEmailString, declaredStudentEmailString = declaredStudentEmailString, - sustainedEngagement = sustainedEngagement, ) @admin_bp.route("/admin/cceMinor/download") diff --git a/app/controllers/admin/volunteers.py b/app/controllers/admin/volunteers.py index 2dbe81479..c16012803 100644 --- a/app/controllers/admin/volunteers.py +++ b/app/controllers/admin/volunteers.py @@ -115,7 +115,6 @@ def volunteerDetailsPage(eventID): waitlistUser = list(set([obj for obj in eventRsvpData if obj.rsvpWaitlist])) rsvpUser = list(set([obj for obj in eventRsvpData if not obj.rsvpWaitlist ])) - return render_template("/events/volunteerDetails.html", waitlistUser = waitlistUser, attendedUser= eventParticipantData, diff --git a/app/controllers/main/routes.py b/app/controllers/main/routes.py index f9d17d1c4..6f83f32dd 100644 --- a/app/controllers/main/routes.py +++ b/app/controllers/main/routes.py @@ -26,7 +26,7 @@ from app.models.courseInstructor import CourseInstructor from app.models.backgroundCheckType import BackgroundCheckType -from app.logic.events import getUpcomingEventsForUser, getParticipatedEventsForUser, getTrainingEvents, getEventRsvpCountsForTerm, getUpcomingVolunteerOpportunitiesCount, getVolunteerOpportunities, getBonnerEvents, getCeltsLabor, getEngagementEvents +from app.logic.events import getUpcomingEventsForUser, getParticipatedEventsForUser, getTrainingEvents, getEventRsvpCountsForTerm, getUpcomingVolunteerOpportunitiesCount, getVolunteerOpportunities, getBonnerEvents, getCeltsLabor, getEngagementEvents, getPastVolunteerOpportunitiesCount from app.logic.transcript import * from app.logic.loginManager import logout from app.logic.searchUsers import searchUsers @@ -92,6 +92,7 @@ def events(selectedTerm, activeTab, programID): currentEventRsvpAmount = getEventRsvpCountsForTerm(term) volunteerOpportunities = getVolunteerOpportunities(term) countUpcomingVolunteerOpportunities = getUpcomingVolunteerOpportunitiesCount(term, currentTime) + countPastVolunteerOpportunities = getPastVolunteerOpportunitiesCount(term, currentTime) trainingEvents = getTrainingEvents(term, g.current_user) engagementEvents = getEngagementEvents(term) bonnerEvents = getBonnerEvents(term) @@ -109,6 +110,8 @@ def events(selectedTerm, activeTab, programID): # Get the count of all term events for each category to display in the event list page. volunteerOpportunitiesCount: int = len(studentEvents) + countUpcomingVolunteerOpportunitiesCount: int = len(countUpcomingVolunteerOpportunities) + countPastVolunteerOpportunitiesCount: int = len(countPastVolunteerOpportunities) trainingEventsCount: int = len(trainingEvents) engagementEventsCount: int = len(engagementEvents) bonnerEventsCount: int = len(bonnerEvents) @@ -133,6 +136,8 @@ def events(selectedTerm, activeTab, programID): if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({ "volunteerOpportunitiesCount": volunteerOpportunitiesCount, + "countPastVolunteerOpportunitiesCount": countPastVolunteerOpportunitiesCount, + "countUpcomingVolunteerOpportunitiesCount": countUpcomingVolunteerOpportunitiesCount, "trainingEventsCount": trainingEventsCount, "engagementEventsCount": engagementEventsCount, "bonnerEventsCount": bonnerEventsCount, @@ -155,6 +160,7 @@ def events(selectedTerm, activeTab, programID): programID = int(programID), managersProgramDict = managersProgramDict, countUpcomingVolunteerOpportunities = countUpcomingVolunteerOpportunities, + countPastVolunteerOpportunities = countPastVolunteerOpportunities, toggleState = toggleState, ) @@ -387,6 +393,7 @@ def addNote(): flash("Failed to add profile note", "danger") return "Failed to add profile note", 500 + @main_bp.route('//deleteNote', methods=['POST']) def deleteNote(username): """ @@ -493,24 +500,18 @@ def volunteerRegister(): program = event.program user = g.current_user - isAdded = checkUserRsvp(user, event) isEligible = isEligibleForProgram(program, user) - listOfRequirements = unattendedRequiredEvents(program, user) personAdded = False if isEligible: personAdded = addPersonToEvent(user, event) - if personAdded and listOfRequirements: - reqListToString = ', '.join(listOfRequirements) - flash(f"{user.firstName} {user.lastName} successfully registered. However, the following training may be required: {reqListToString}.", "success") - elif personAdded: + if personAdded: flash("Successfully registered for event!","success") else: flash(f"RSVP Failed due to an unknown error.", "danger") else: flash(f"Cannot RSVP. Contact CELTS administrators: {app.config['celts_admin_contact']}.", "danger") - if 'from' in request.form: if request.form['from'] == 'ajax': return '' @@ -545,9 +546,11 @@ def serviceTranscript(username): slCourses = getSlCourseTranscript(username) totalHours = getTotalHours(username) allEventTranscript = getProgramTranscript(username) + zeroHourEvents = getZeroHourEvents(username) startDate = getStartYear(username) return render_template('main/serviceTranscript.html', allEventTranscript = allEventTranscript, + zeroHourEvents = zeroHourEvents, slCourses = slCourses.objects(), totalHours = totalHours, startDate = startDate, diff --git a/app/controllers/minor/routes.py b/app/controllers/minor/routes.py index d4114c7f4..64575ba4f 100644 --- a/app/controllers/minor/routes.py +++ b/app/controllers/minor/routes.py @@ -3,24 +3,35 @@ from app.controllers.minor import minor_bp from app.models.user import User -from app.models.attachmentUpload import AttachmentUpload +from app.models.cceMinorProposal import CCEMinorProposal from app.models.term import Term +from app.models.attachmentUpload import AttachmentUpload from app.logic.fileHandler import FileHandler from app.logic.utils import selectSurroundingTerms, getFilesFromRequest -from app.logic.minor import createOtherEngagementRequest, setCommunityEngagementForUser, getSummerExperience, getEngagementTotal, createSummerExperience, getProgramEngagementHistory, getCourseInformation, getCommunityEngagementByTerm, getCCEMinorProposals, createOtherEngagementRequest, removeProposal, getMinorSpreadsheet +from app.logic.minor import ( + changeProposalStatus, + createOtherEngagement, + updateOtherEngagementRequest, + setCommunityEngagementForUser, + getEngagementTotal, + createSummerExperience, + updateSummerExperience, + getProgramEngagementHistory, + getCourseInformation, + getCommunityEngagementByTerm, + getMinorSpreadsheet, + getCCEMinorProposals, + removeProposal +) @minor_bp.route('/profile//cceMinor', methods=['GET']) def viewCceMinor(username): """ - Load minor management page with community engagements and summer experience + Load minor management page with community engagements and summer experience """ - if not (g.current_user.isAdmin): - return abort(403) - sustainedEngagementByTerm = getCommunityEngagementByTerm(username) activeTab = request.args.get("tab", "sustainedCommunityEngagements") - return render_template("minor/profile.html", user = User.get_by_id(username), proposalList = getCCEMinorProposals(username), @@ -29,55 +40,94 @@ def viewCceMinor(username): activeTab=activeTab) @minor_bp.route('/cceMinor//otherEngagement', methods=['GET', 'POST']) -def requestOtherEngagement(username): - """ - Load minor management page with community engagements and summer experience - """ +def createOtherEngagementRequest(username): if not (g.current_user.isAdmin or g.current_user.username == username): return abort(403) - # once we submit the form for creation if request.method == "POST": - createdProposal = createOtherEngagementRequest(username, request.form) - attachment = request.files.get("attachmentObject") - if attachment: - addFile = FileHandler(getFilesFromRequest(request), proposalId=createdProposal.id) - addFile.saveFiles() - - return redirect(url_for('minor.viewCceMinor', username=username)) + createOtherEngagement(username, request) + flash("Proposal successfully created.", "success") + return redirect(url_for('minor.viewCceMinor', username=username, tab="manageProposals")) return render_template("minor/requestOtherEngagement.html", + editable = True, user = User.get_by_id(username), selectableTerms = selectSurroundingTerms(g.current_term), - allTerms = getSummerExperience(username)) + postRoute = f"/cceMinor/{username}/otherEngagement", # when form is submitted, what POST route is it being submitted to. + attachmentFilePath = "", + attachmentFileName = "", + proposal = None) + +@minor_bp.route('/cceMinor/viewOtherEngagement/', methods=['GET']) +@minor_bp.route('/cceMinor/viewSummerExperience/', methods=['GET']) +@minor_bp.route('/cceMinor/editOtherEngagement/', methods=['GET', 'POST']) +@minor_bp.route('/cceMinor/editSummerExperience/', methods=['GET', 'POST']) +def editOrViewProposal(proposalID: int): + proposal = CCEMinorProposal.get_by_id(int(proposalID)) + if not (g.current_user.isAdmin or g.current_user.username == proposal.student.username): + return abort(403) + + editProposal = 'view' not in request.path + # if proposal is approved or completed, only admins can edit, but not if the admin is the student + if proposal.isApproved and editProposal: + if g.current_user.username == proposal.student.username or not g.current_user.isAdmin: + return abort(403) + + attachmentObject = AttachmentUpload.get_or_none(proposal=proposalID) + attachmentFilePath = "" + attachmentFileName = "" + + if attachmentObject: + fileHandler = FileHandler(proposalId=proposalID) + attachmentFilePath = fileHandler.getFileFullPath(attachmentObject.fileName).lstrip("app/") # we need to remove app/ from the url because it prevents it from displaying + attachmentFileName = attachmentObject.fileName + + if request.method == "GET": + selectedTerm = Term.get_by_id(proposal.term) + flash("Once approved, a proposal can only be edited by an admin.", 'warning') + return render_template("minor/requestOtherEngagement.html" if 'OtherEngagement' in request.path else "minor/summerExperience.html", + editable = editProposal, + selectedTerm = selectedTerm, + contentAreas = proposal.contentAreas.split(", ") if proposal.contentAreas else [], + selectableTerms = selectSurroundingTerms(g.current_term, summerOnly=False if 'OtherEngagement' not in request.path else True), + user = User.get_by_id(proposal.student), + postRoute = f"/cceMinor/editSummerExperience/{proposal.id}" if "SummerExperience" in request.path else f"/cceMinor/editOtherEngagement/{proposal.id}", + proposal = proposal, + attachmentFilePath = attachmentFilePath, + attachmentFileName = attachmentFileName + ) + + if "OtherEngagement" in request.path: + updateOtherEngagementRequest(proposalID, request) + else: + updateSummerExperience(proposalID, request.form) + + flash("Proposal updated", "success") + return redirect(url_for('minor.viewCceMinor', username=proposal.student, tab='manageProposals')) @minor_bp.route('/cceMinor//summerExperience', methods=['GET', 'POST']) -def requestSummerExperience(username): - """ - Load minor management page with community engagements and summer experience - """ +def createSummerExperienceRequest(username): if not (g.current_user.isAdmin or g.current_user.username == username): return abort(403) # once we submit the form for creation if request.method == "POST": createSummerExperience(username, request.form) - return redirect(url_for('minor.viewCceMinor', username=username)) + flash("Proposal successfully created.", "success") + return redirect(url_for('minor.viewCceMinor', username=username, tab="manageProposals")) summerTerms = selectSurroundingTerms(g.current_term, summerOnly=True) return render_template("minor/summerExperience.html", - summerTerms = summerTerms, + selectableTerms = summerTerms, + contentAreas = [], user = User.get_by_id(username), ) @minor_bp.route('/cceMinor//getEngagementInformation///', methods=['GET']) def getEngagementInformation(username, type, id, term): - """ - For a particular engagement activity (program or course), get the participation history or course information respectively. - """ if type == "program": information = getProgramEngagementHistory(id, username, term) else: @@ -86,19 +136,38 @@ def getEngagementInformation(username, type, id, term): return information -@minor_bp.route('/cceMinor/withdraw//', methods = ['POST']) -def withdrawProposal(username, proposalID): +@minor_bp.route('/cceMinor///', methods=['POST']) +def updateProposal(action, username, proposalId): try: - if g.current_user.isAdmin or g.current_user.isFaculty or g.current_user == username: - removeProposal(proposalID) - flash("Experience successfully withdrawn", 'success') + if not (g.current_user.isAdmin or g.current_user.isFaculty or g.current_user.username == username): + flash("Unauthorized to perform this action", "warning") + return "" + + actionMap = { + "withdraw": ("Withdrawn", "Proposal successfully withdrawn"), + "complete": ("Completed", "Proposal successfully completed"), + "approve": ("Approved", "Proposal approved"), + "unapprove": ("Submitted", "Proposal unapproved"), + } + + if action not in actionMap: + flash("Invalid action", "warning") + return "" + + newStatus, message = actionMap[action] + + if action == "withdraw": + removeProposal(proposalId) else: - flash("Unauthorized to perform this action", 'warning') + changeProposalStatus(proposalId, newStatus) + flash(message, "success") + except Exception as e: print(e) - flash("Withdrawal Unsuccessful", 'warning') + flash("Proposal status could not be changed", "warning") + return "" - + @minor_bp.route('/cceMinor/getMinorSpreadsheet', methods=['GET']) def returnMinorSpreadsheet(): diff --git a/app/controllers/serviceLearning/routes.py b/app/controllers/serviceLearning/routes.py index be08837e6..40f76fbda 100644 --- a/app/controllers/serviceLearning/routes.py +++ b/app/controllers/serviceLearning/routes.py @@ -205,12 +205,16 @@ def withdrawCourse(courseID): if g.current_user.isAdmin or g.current_user.isFaculty: withdrawProposal(courseID) flash("Course successfully withdrawn", 'success') + return "" else: flash("Unauthorized to perform this action", 'warning') + return "", 403 except Exception as e: print(e) - flash("Withdrawal Unsuccessful", 'warning') - return "" + flash("Error: Failed to withdraw course", 'alert') + + + return "", 500 @serviceLearning_bp.route('/proposalReview/', methods = ['GET', 'POST']) diff --git a/app/logic/bonner.py b/app/logic/bonner.py index a6cfc3717..47fcbe84a 100644 --- a/app/logic/bonner.py +++ b/app/logic/bonner.py @@ -15,7 +15,7 @@ from app.models.eventCohort import EventCohort from app.models.term import Term from app.logic.createLogs import createRsvpLog - +from app.models.certification import Certification def makeBonnerXls(selectedYear, noOfYears=1): """ Create and save a BonnerStudents.xlsx file with all of the current and former bonner students. @@ -44,10 +44,8 @@ def makeBonnerXls(selectedYear, noOfYears=1): worksheet.set_column('D:D', 20) # bonner event titles - bonnerEventsId = 1 - bonnerEvents = CertificationRequirement.select().where(CertificationRequirement.certification==bonnerEventsId).order_by(CertificationRequirement.order.asc()) + bonnerEvents = CertificationRequirement.select().where(CertificationRequirement.certification==Certification.BONNER).order_by(CertificationRequirement.order.asc()) bonnerEventInfo = {bonnerEvent.id:(bonnerEvent.name, index + 4) for index, bonnerEvent in enumerate(bonnerEvents)} - allBonnerSpreadsheetPosition = 7 currentLetter = "E" # next column for bonnerEvent in bonnerEvents: worksheet.write(f"{currentLetter}1", bonnerEvent.name, bold) @@ -84,21 +82,19 @@ def makeBonnerXls(selectedYear, noOfYears=1): .join(EventParticipant, on=(RequirementMatch.event == EventParticipant.event)) .join(CertificationRequirement, on=(RequirementMatch.requirement == CertificationRequirement.id)) .join(User, on=(EventParticipant.user == User.username)) - .where((CertificationRequirement.certification_id == bonnerEventsId) & (User.username == student.user.username)) + .where((CertificationRequirement.certification_id == Certification.BONNER) & (User.username == student.user.username)) ) - allBonnerMeetingDates = [] + certRequirementDates = {} for attendedEvent in bonnerEventsAttended: if bonnerEventInfo.get(attendedEvent.requirement.id): completedEvent = bonnerEventInfo[attendedEvent.requirement.id] - worksheet.write(row, completedEvent[1], attendedEvent.event.startDate.strftime('%m/%d/%Y')) - if completedEvent[0] == "All Bonner Meeting": - allBonnerMeetingDates.append(attendedEvent.event.startDate.strftime('%m/%d/%Y')) - else: - raise Exception("Untracked requirements found in attended events. Debug required.") - - worksheet.write(row, allBonnerSpreadsheetPosition, ", ".join(sorted(allBonnerMeetingDates))) + if completedEvent[1] not in certRequirementDates: + certRequirementDates[completedEvent[1]] = [] + certRequirementDates[completedEvent[1]].append(attendedEvent.event.startDate.strftime('%m/%d/%Y')) + for tableIndex, dates in certRequirementDates.items(): + worksheet.write(row, tableIndex, ", ".join(sorted(dates))) row += 1 workbook.close() diff --git a/app/logic/events.py b/app/logic/events.py index db921d149..8a26f58fd 100644 --- a/app/logic/events.py +++ b/app/logic/events.py @@ -1,5 +1,5 @@ from flask import url_for, g, session -from peewee import DoesNotExist, fn, JOIN +from peewee import DoesNotExist, fn, JOIN, Case, Value from dateutil import parser from datetime import timedelta, date, datetime from dateutil.relativedelta import relativedelta @@ -118,8 +118,9 @@ def attemptSaveMultipleOfferings(eventData, attachmentFiles = None): seriesId = calculateNewSeriesId() - # Create separate event data inheriting from the original eventData - seriesData = eventData.get('seriesData') + # Create separate event data for each event in the series, inheriting from the original eventData + seriesData = sorted(eventData.get('seriesData'), key=lambda x: datetime.strptime(x['eventDate'].split(' ')[0] + ' ' + x['startTime'], '%Y-%m-%d %H:%M')) + # sorts the events in the series by date and time so that the events are created in order and the naming convention of Week 1, Week 2, etc. is consistent with the order of the events. isRepeating = bool(eventData.get('isRepeating')) with mainDB.atomic() as transaction: for index, event in enumerate(seriesData): @@ -129,8 +130,9 @@ def attemptSaveMultipleOfferings(eventData, attachmentFiles = None): 'startDate': event['eventDate'], 'timeStart': event['startTime'], 'timeEnd': event['endTime'], + 'location': event['eventLocation'], 'seriesId': seriesId, - 'isRepeating': bool(isRepeating) + 'isRepeating': bool(isRepeating), }) # Try to save each offering savedEvents, validationErrorMessage = attemptSaveEvent(eventInfo, attachmentFiles) @@ -173,7 +175,7 @@ def attemptSaveEvent(eventData, attachmentFiles = None, renewedEvent = False): if attachmentFiles: for event in events: addFile = FileHandler(attachmentFiles, eventId=event.id) - addFile.saveFiles(saveOriginalFile=events[0]) + addFile.saveFiles(parentEvent=events[0]) return events, "" @@ -251,7 +253,7 @@ def getEngagementEvents(term): .execute()) return engagementEvents -def getUpcomingVolunteerOpportunitiesCount(term, currentTime): +def getUpcomingVolunteerOpportunitiesCount(term, currentDate): """ Return a count of all upcoming events for each volunteer opportunitiesprogram. """ @@ -265,8 +267,8 @@ def getUpcomingVolunteerOpportunitiesCount(term, currentTime): (Event.deletionDate.is_null(True)) & (Event.isService == True) & ((Event.isLaborOnly == False) | Event.isLaborOnly.is_null(True)) & - ((Event.startDate > currentTime) | - ((Event.startDate == currentTime) & (Event.timeEnd >= currentTime))) & + ((Event.startDate > currentDate) | + ((Event.startDate == currentDate) & (Event.timeEnd >= currentDate.time()))) & (Event.isCanceled == False) ) .group_by(Program.id) @@ -277,6 +279,32 @@ def getUpcomingVolunteerOpportunitiesCount(term, currentTime): programCountDict[programCount.id] = programCount.eventCount return programCountDict +def getPastVolunteerOpportunitiesCount(term, currentDate): + """ + Return a count of all past events for each volunteer opportunities program. + """ + + pastCount = ( + Program + .select(Program.id, fn.COUNT(Event.id).alias("eventCount")) + .join(Event, on=(Program.id == Event.program_id)) + .where( + (Event.term == term) & + (Event.deletionDate.is_null(True)) & + (Event.isService == True) & + ((Event.isLaborOnly == False) | Event.isLaborOnly.is_null(True)) & + ((Event.startDate < currentDate) | + ((Event.startDate == currentDate) & (Event.timeStart <= currentDate.time()))) & + (Event.isCanceled == False) + ) + .group_by(Program.id) + ) + + programCountDict = {} + for programCount in pastCount: + programCountDict[programCount.id] = programCount.eventCount + return programCountDict + def getTrainingEvents(term, user): """ The allTrainingsEvent query is designed to select and count eventId's after grouping them @@ -371,20 +399,24 @@ def getParticipatedEventsForUser(user): :return: A list of Event objects """ - participatedEvents = (Event.select(Event, Program.programName) + eventName = fn.LOWER(Event.name) + checkIfLaborMeeting = eventName.contains("labor meeting") + + participatedEvents = (Event.select(Event, Program.programName, Case(None, ( + ((Event.isLaborOnly | Event.name.contains("Labor")) & Event.isService, "Labor & Volunteer"), + ((Event.isLaborOnly | Event.name.contains("Labor")), "Labor"), + (Event.isService, "Volunteer")), "Attendee").alias("participatedType")) .join(Program, JOIN.LEFT_OUTER).switch() .join(EventParticipant) .where(EventParticipant.user == user, - Event.isAllVolunteerTraining == False, Event.deletionDate == None) + Event.isAllVolunteerTraining == False, Event.deletionDate == None, ~checkIfLaborMeeting) .order_by(Event.startDate, Event.name)) - - allVolunteer = (Event.select(Event, "") + allVolunteer = (Event.select(Event, "", Value("Volunteer").alias("participatedType")) .join(EventParticipant) .where(Event.isAllVolunteerTraining == True, EventParticipant.user == user)) union = participatedEvents.union_all(allVolunteer) - 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()) - + unionParticipationWithVolunteer = list(union.select_from(union.c.id, union.c.programName, union.c.startDate, union.c.name, union.c.participatedType).order_by(union.c.startDate, union.c.name).execute()) return unionParticipationWithVolunteer def validateNewEventData(data): @@ -458,7 +490,9 @@ def getRepeatingEventsData(eventData): return [ {'name': f"{eventData['name']} Week {counter+1}", 'date': eventData['startDate'] + timedelta(days=7*counter), - "week": counter+1} + "week": counter+1, + 'location': eventData['location'] + } for counter in range(0, ((eventData['endDate']-eventData['startDate']).days//7)+1)] def preprocessEventData(eventData): diff --git a/app/logic/fileHandler.py b/app/logic/fileHandler.py index 11ea58cc7..756292a6c 100644 --- a/app/logic/fileHandler.py +++ b/app/logic/fileHandler.py @@ -24,11 +24,14 @@ def __init__(self, files=None, courseId=None, eventId=None, programId=None, prop elif programId: self.path = os.path.join(self.path, app.config['files']['program_attachment_path']) elif proposalId: - self.path = os.path.join(self.path, app.config['files']['proposal_attachment_path']) + self.path = os.path.join(self.path, app.config['files']['proposal_attachment_path'], str(proposalId)) def makeDirectory(self): try: - extraDir = str(self.eventId) if self.eventId else "" + extraDir = "" + if self.eventId: + extraDir = str(self.eventId) + os.makedirs(os.path.join(self.path, extraDir)) except OSError as e: if e.errno != 17: @@ -46,57 +49,53 @@ def getFileFullPath(self, newfilename=''): return filePath - def saveFiles(self, saveOriginalFile=None): - try: - for file in self.files: - saveFileToFilesystem = None - - if self.eventId: - attachmentName = str(saveOriginalFile.id) + "/" + file.filename - isFileInEvent = AttachmentUpload.select().where(AttachmentUpload.event_id == self.eventId, - AttachmentUpload.fileName == attachmentName).exists() - if not isFileInEvent: - AttachmentUpload.create(event=self.eventId, fileName=attachmentName) - if saveOriginalFile and saveOriginalFile.id == self.eventId: - saveFileToFilesystem = attachmentName - elif self.courseId: - isFileInCourse = AttachmentUpload.select().where(AttachmentUpload.course == self.courseId, AttachmentUpload.fileName == file.filename).exists() - if not isFileInCourse: - AttachmentUpload.create(course=self.courseId, fileName=file.filename) - saveFileToFilesystem = file.filename - elif self.programId: - - # remove the existing file - deleteFileObject = AttachmentUpload.get_or_none(program=self.programId) - if deleteFileObject: - self.deleteFile(deleteFileObject.id) - - # add the new file - fileType = file.filename.split('.')[-1] - fileName = f"{self.programId}.{fileType}" - AttachmentUpload.create(program=self.programId, fileName=fileName) - currentProgramID = fileName - saveFileToFilesystem = currentProgramID - - elif self.proposalId: - fileType = file.filename.split('.')[-1] - fileName = f"{self.proposalId}.{fileType}" - isFileInProposal = AttachmentUpload.select().where(AttachmentUpload.proposal == self.proposalId, - AttachmentUpload.fileName == fileName).exists() - if not isFileInProposal: - # add the new file - AttachmentUpload.create(proposal=self.proposalId, fileName=fileName) - saveFileToFilesystem = fileName - - else: - saveFileToFilesystem = file.filename - - if saveFileToFilesystem: - self.makeDirectory() - file.save(self.getFileFullPath(newfilename=saveFileToFilesystem)) - - except AttributeError as e: - print(e) + def saveFiles(self, parentEvent=None): + """ + Saves attachments for different types and creates DB record for stored attachment + """ + for file in self.files: + saveFileToFilesystem = None + + if self.eventId: + attachmentName = str(parentEvent.id) + "/" + file.filename + fileObject, created = AttachmentUpload.get_or_create( + event_id = self.eventId, + fileName = attachmentName + ) + + if created and parentEvent and parentEvent.id == self.eventId: + saveFileToFilesystem = attachmentName + + elif self.courseId or self.proposalId: + recordId = self.courseId if self.courseId else self.proposalId + fieldName = 'course' if self.courseId else 'proposal' + fileData = { + fieldName: recordId, + "fileName": file.filename + } + fileObject, created = AttachmentUpload.get_or_create(**fileData) + saveFileToFilesystem = file.filename if created else None + + elif self.programId: + + # remove the existing file + deleteFileObject = AttachmentUpload.get_or_none(program=self.programId) + if deleteFileObject: + self.deleteFile(deleteFileObject.id) + + # add the new file + fileType = file.filename.split('.')[-1] + fileName = f"{self.programId}.{fileType}" + AttachmentUpload.create(program=self.programId, fileName=fileName) + currentProgramID = fileName + saveFileToFilesystem = currentProgramID + + else: + saveFileToFilesystem = file.filename + + if saveFileToFilesystem: + self.makeDirectory() + file.save(self.getFileFullPath(newfilename=saveFileToFilesystem)) def retrievePath(self, files): pathDict = {} diff --git a/app/logic/graduationManagement.py b/app/logic/graduationManagement.py index f05478b81..4d3284215 100644 --- a/app/logic/graduationManagement.py +++ b/app/logic/graduationManagement.py @@ -13,7 +13,7 @@ def getGraduationManagementUsers(): eligibleUsers = (User.select(User.username, User.hasGraduated, User.rawClassLevel, User.firstName, User.lastName, BonnerCohort.year) .join(BonnerCohort, JOIN.LEFT_OUTER, on=(BonnerCohort.user == User.username)) - .where((User.rawClassLevel == 'Senior') | (User.rawClassLevel == "Graduating") | (User.hasGraduated == True))) + .where((User.rawClassLevel == 'Senior') | (User.rawClassLevel == "Graduating") | (User.hasGraduated == True) )) cceStudents = set([user["username"] for user in getMinorProgress()]) @@ -27,16 +27,13 @@ def getGraduationManagementUsers(): return graduationManagementUsers - def setGraduatedStatus(username, status): - """ - Update a students graduation status based on the parameter status. - """ gradStudent = User.get(User.username == username) - + # it is necessary we cast this to an int instead of a bool because the # status is passed as a string and if we cast it to a bool it will always be True - gradStudent.hasGraduated = int(status) + gradStudent.hasGraduated = int(status) gradStudent.save() - \ No newline at end of file + + diff --git a/app/logic/minor.py b/app/logic/minor.py index 955a124bc..0d2beb978 100644 --- a/app/logic/minor.py +++ b/app/logic/minor.py @@ -5,7 +5,7 @@ from peewee import JOIN, fn, Case, DoesNotExist, SQL import xlsxwriter -from app import app +from app.models import app from app.models.user import User from app.models.term import Term from app.models.event import Event @@ -18,9 +18,8 @@ from app.models.individualRequirement import IndividualRequirement from app.models.certificationRequirement import CertificationRequirement from app.models.cceMinorProposal import CCEMinorProposal -from app.logic.createLogs import createActivityLog from app.logic.fileHandler import FileHandler -from app.logic.serviceLearningCourses import deleteCourseObject +from app.logic.utils import getFilesFromRequest from app.models.attachmentUpload import AttachmentUpload @@ -29,37 +28,31 @@ def createSummerExperience(username, formData): Given the username of the student and the formData which includes all of the SummerExperience information, create a new SummerExperience object. """ - try: - user = User.get(User.username == username) - contentAreas = ', '.join(formData.getlist('contentArea')) # Combine multiple content areas - CCEMinorProposal.create( - student=user, - proposalType = 'Summer Experience', - contentAreas = contentAreas, - status="Pending", - createdBy = g.current_user, - **formData, - ) - except Exception as e: - print(f"Error saving summer experience: {e}") - raise e + user = User.get(User.username == username) + contentAreas = ', '.join(formData.getlist('contentArea')) # Combine multiple content areas + formData = dict(formData) + formData.pop("contentArea") + return CCEMinorProposal.create( + student=user, + proposalType = 'Summer Experience', + contentAreas = contentAreas, + createdBy = g.current_user, + **formData, + ) -def getCCEMinorProposals(username): - proposalList = [] - - cceMinorProposals = list(CCEMinorProposal.select().where(CCEMinorProposal.student==username)) - - for experience in cceMinorProposals: - proposalList.append({ - "id": experience.id, - "type": experience.proposalType, - "createdBy": experience.createdBy, - "supervisor": experience.supervisorName, - "term": experience.term, - "status": experience.status, - }) +def updateSummerExperience(proposalID, formData): + """ + Given the username of the student and the formData which includes all of + the SummerExperience information, create a new SummerExperience object. + """ + contentAreas = ', '.join(formData.getlist('contentArea')) # Combine multiple content areas + formData = dict(formData) + formData.pop("contentArea") + formData.pop("experienceHoursOver300") + CCEMinorProposal.update(contentAreas=contentAreas, **formData).where(CCEMinorProposal.id == proposalID).execute() - return proposalList +def getCCEMinorProposals(username): + return list(CCEMinorProposal.select().where(CCEMinorProposal.student==username)) def getEngagementTotal(engagementData): """ @@ -97,7 +90,10 @@ def getMinorProgress(): .join(IndividualRequirement, on=(User.username == IndividualRequirement.username)) .join(CertificationRequirement, on=(IndividualRequirement.requirement_id == CertificationRequirement.id)) .switch(User).join(CCEMinorProposal, JOIN.LEFT_OUTER, on= (User.username == CCEMinorProposal.student)) - .where(CertificationRequirement.certification_id == Certification.CCE) + .where( + (CertificationRequirement.certification_id == Certification.CCE) & + (User.declaredMinor == True) + ) .group_by(User.firstName, User.lastName, User.username) .order_by(SQL("engagementCount").desc()) ) @@ -184,13 +180,71 @@ def declareMinorInterest(username): def getDeclaredMinorStudents(): """ - Get a list of the students who have declared minor + This function retrieves a list of students who have declared the CCE minor along with their engagement progress. + It returns a list of dictionaries containing student information and their engagement details and adds students who have no requirements but have declared the minor with 0 engagements. """ - declaredStudents = User.select().where(User.isStudent & User.declaredMinor) + summerEngagementCount = fn.COUNT( + fn.DISTINCT( + Case( + None, + [(CCEMinorProposal.proposalType == "Summer Experience", CCEMinorProposal.id)], + None + ) + ) + ).alias("summerEngagementCount") + + # this returns the count of distinct engagements that have a certification requirement id. + # this is important because our join clause specifically joins individualrequirements with the certifications that match to CCE + # while leaving the rest as null + cceEngagementCount = fn.COUNT( + fn.DISTINCT( + Case( + None, + [(CertificationRequirement.id.is_null(False), IndividualRequirement.id)], + None + ) + ) + ).alias("allEngagementCount") + + q = ( + User + .select( + User, + cceEngagementCount, + summerEngagementCount, + fn.IF(fn.COUNT(fn.DISTINCT(CCEMinorProposal.id)) > 0, True, False).alias("hasCCEMinorProposal"), + ) + .join(IndividualRequirement, JOIN.LEFT_OUTER, on=(User.username == IndividualRequirement.username)) + .join(CertificationRequirement, JOIN.LEFT_OUTER, on=( + (IndividualRequirement.requirement_id == CertificationRequirement.id) & + (CertificationRequirement.certification_id == Certification.CCE) # only cce minor certs are populated with non-null + )) + .switch(User) + .join(CCEMinorProposal, JOIN.LEFT_OUTER, on=(User.username == CCEMinorProposal.student)) + .where( + (User.declaredMinor == True) & + (User.isStudent == True) + ) + .group_by(User.username) + .order_by(SQL("allEngagementCount").desc()) + ) - interestedStudentList = [model_to_dict(student) for student in declaredStudents] + result = [] + for s in q: + engagementCount = int(s.allEngagementCount or 0) + result.append({ + "firstName": s.firstName, + "lastName": s.lastName, + "username": s.username, + "B-Number": s.bnumber, + "email": s.email, + "hasGraduated": s.hasGraduated, + "engagementCount": engagementCount, + "hasCCEMinorProposal": bool(s.hasCCEMinorProposal), + "hasSummer": "Completed" if (s.summerEngagementCount and int(s.summerEngagementCount) > 0) else "Incomplete", + }) - return interestedStudentList + return result def getCourseInformation(id): """ @@ -269,6 +323,7 @@ def setCommunityEngagementForUser(action, engagementData, currentUser): "requirement": requirement.get(), "addedBy": currentUser, }) + # Thrown if there are no available engagement requirements left. Handled elsewhere. except DoesNotExist as e: raise e @@ -279,6 +334,7 @@ def setCommunityEngagementForUser(action, engagementData, currentUser): IndividualRequirement.username == engagementData['username'], IndividualRequirement.term == engagementData['term'] ).execute() + else: raise Exception(f"Invalid action '{action}' sent to setCommunityEngagementForUser") @@ -330,21 +386,45 @@ def getCommunityEngagementByTerm(username): # sorting the communityEngagementByTermDict by the term id return dict(sorted(communityEngagementByTermDict.items(), key=lambda engagement: engagement[0][1])) -def createOtherEngagementRequest(username, formData): +def createOtherEngagement(username, request): """ - Create a CCEMinorProposal entry based off of the form data + Create a CCEMinorProposal entry based off of the form data """ user = User.get(User.username == username) - cceObject = CCEMinorProposal.create(proposalType = 'Other Engagement', - createdBy = g.current_user, - status = 'Pending', - student = user, - **formData + createdProposal = CCEMinorProposal.create(proposalType = 'Other Engagement', + createdBy = g.current_user, + student = user, + **request.form ) + attachment = request.files.get("attachmentObject") + if attachment: + addFile = FileHandler(getFilesFromRequest(request), proposalId=createdProposal.id) + addFile.saveFiles() - return cceObject +def updateOtherEngagementRequest(proposalID, request): + """ + Update an existing CCEMinorProposal entry based off of the form data + """ + newAttachment = request.files.get("attachmentObject") + previousAttachment = AttachmentUpload.get_or_none(proposal=proposalID) + proposalObject = CCEMinorProposal.get_by_id(proposalID) + deleteAttachment = request.form.get("deleteAttachment") == "true" + if deleteAttachment: + if not previousAttachment: + raise AssertionError("deleteAttachment flag is set but no attachment exists in the database.") + FileHandler(proposalId=proposalID).deleteFile(previousAttachment.id) + elif newAttachment and newAttachment.filename: + if previousAttachment: + FileHandler(proposalId=proposalID).deleteFile(previousAttachment.id) + addFile = FileHandler(getFilesFromRequest(request), proposalId=proposalID) + addFile.saveFiles(parentEvent=proposalObject) + + formData = request.form.copy() + formData.pop("deleteAttachment", None) + CCEMinorProposal.update(**formData).where(CCEMinorProposal.id == proposalID).execute() + def saveSummerExperience(username, summerExperience, currentUser): """ :param username: username of the student that the summer experience is for @@ -374,6 +454,7 @@ def saveSummerExperience(username, summerExperience, currentUser): "requirement": requirement.get(), "addedBy": currentUser, }) + return "" def getSummerExperience(username): @@ -411,3 +492,9 @@ def removeProposal(proposalID) -> None: proposalFileHandler.deleteFile(proposalAttachment.id) CCEMinorProposal.delete().where(CCEMinorProposal.id == proposalID).execute() + +def changeProposalStatus(proposalID, newStatus) -> None: + """ + Changes the status of a proposal. + """ + CCEMinorProposal.update(status=newStatus).where(CCEMinorProposal.id == int(proposalID)).execute() diff --git a/app/logic/transcript.py b/app/logic/transcript.py index dcbfbd3b7..4e09757c7 100644 --- a/app/logic/transcript.py +++ b/app/logic/transcript.py @@ -42,6 +42,25 @@ def getProgramTranscript(username): return dict(transcriptData) +def getZeroHourEvents(username): + """ + Returns a list of events with zero hours earned for the given user, + excluding deleted and canceled events. + """ + zeroHourEvents = (Event.select(Event, Program, Term) + .join(EventParticipant) + .switch(Event) + .join(Program) + .switch(Event) + .join(Term) + .where(EventParticipant.user == username, + EventParticipant.hoursEarned == 0, + Event.deletionDate == None, + Event.isCanceled == False) + .order_by(Event.term)) + + return list(zeroHourEvents) + def getSlCourseTranscript(username): """ Returns a SLCourse query object containing all the training events for diff --git a/app/logic/volunteerSpreadsheet.py b/app/logic/volunteerSpreadsheet.py index 6a03ca6ac..997002b0d 100644 --- a/app/logic/volunteerSpreadsheet.py +++ b/app/logic/volunteerSpreadsheet.py @@ -1,12 +1,11 @@ -from importlib.abc import ResourceReader from os import major -import xlsxwriter +import xlsxwriter from peewee import fn, Case, JOIN, SQL, Select from collections import defaultdict from datetime import date, datetime,time - from app import app from app.models import mainDB +from app.models.celtsLabor import CeltsLabor from app.models.eventParticipant import EventParticipant from app.models.user import User from app.models.program import Program @@ -227,6 +226,51 @@ def calculateRetentionRate(fallDict, springDict): return retentionDict +def laborAttendanceByTerm(term): + fullName = fn.CONCAT(User.firstName, ' ', User.lastName).alias('fullName') + email = fn.CONCAT(User.username, '@berea.edu').alias('email') + meetingsAttended = fn.COUNT(fn.DISTINCT(Event.id)).alias('meetingsAttended') + + validEvent = ( + (EventParticipant.event == Event.id) & + (Event.term == term) & + (Event.isLaborOnly == True) & + (Event.deletionDate.is_null()) & + (Event.isCanceled == False)) + + CLTerm = Term.alias() + laborMembers = ( + CeltsLabor + .select(fn.DISTINCT(CeltsLabor.user_id)) + .join(CLTerm, on=(CeltsLabor.term == CLTerm.id)) + .where( + (CeltsLabor.term == term) | + ((CLTerm.academicYear == term.academicYear) & (CeltsLabor.isAcademicYear == True)) + )) + + laborQuery = ( + CeltsLabor + .select(fullName, User.bnumber, email, meetingsAttended) + .join(User) + .switch(CeltsLabor) + .join(EventParticipant, JOIN.LEFT_OUTER, on=(CeltsLabor.user == EventParticipant.user)) + .join(Event, JOIN.LEFT_OUTER,on=validEvent) + .where(CeltsLabor.user.in_(laborMembers)) + .group_by(CeltsLabor.user)) + + nonLaborQuery = ( + EventParticipant + .select(fullName, User.bnumber, email, meetingsAttended) + .join(User) + .switch(EventParticipant) + .join(Event,on=validEvent) + .where(EventParticipant.user.not_in(laborMembers)) + .group_by(EventParticipant.user)) + + query = laborQuery.union(nonLaborQuery).order_by(SQL('fullName')) + columns = ("Full Name", "B-Number", "Email", "Meetings Attended") + + return (columns, query.tuples()) def makeDataXls(sheetName, sheetData, workbook, sheetDesc=None): # assumes the length of the column titles matches the length of the data @@ -273,9 +317,12 @@ def createSpreadsheet(academicYear): makeDataXls("Unique Volunteers", getUniqueVolunteers(academicYear), workbook, sheetDesc=f"All students who participated in at least one service event during {academicYear}.") makeDataXls("Only All Volunteer Training", onlyCompletedAllVolunteer(academicYear), workbook, sheetDesc="Students who participated in an All Volunteer Training, but did not participate in any service events.") makeDataXls("Retention Rate By Semester", getRetentionRate(academicYear), workbook, sheetDesc="The percentage of students who participated in service events in the fall semester who also participated in a service event in the spring semester. Does not currently account for fall graduations.") - + fallTerm = getFallTerm(academicYear) springTerm = getSpringTerm(academicYear) + makeDataXls(f"Labor Attendance {fallTerm.description}", laborAttendanceByTerm(fallTerm), workbook,sheetDesc=f"Number of labor-only events attended in {fallTerm.description} for each labor student and non-labor attendees, including zero attendance (for labor students).") + makeDataXls(f"Labor Attendance {springTerm.description}", laborAttendanceByTerm(springTerm), workbook, sheetDesc=f"Number of labor-only events attended in {springTerm.description} for each labor student and non-labor attendees, including zero attendance (for labor students).") + makeDataXls(fallTerm.description, getAllTermData(fallTerm), workbook, sheetDesc= "All event participation for the term, excluding deleted or canceled events.") makeDataXls(springTerm.description, getAllTermData(springTerm), workbook, sheetDesc="All event participation for the term, excluding deleted or canceled events.") diff --git a/app/models/cceMinorProposal.py b/app/models/cceMinorProposal.py index 9bb4d27e5..2d0ae1763 100644 --- a/app/models/cceMinorProposal.py +++ b/app/models/cceMinorProposal.py @@ -22,13 +22,16 @@ class CCEMinorProposal(baseModel): supervisorEmail = CharField() totalHours = IntegerField(null=True) totalWeeks = IntegerField(null=True) - description = TextField() createdOn = DateTimeField(default=datetime.datetime.now) createdBy = ForeignKeyField(User) - status = CharField(constraints=[Check("status in ('Approved', 'Pending', 'Denied')")]) + status = CharField(constraints=[Check("status in ('Draft', 'Submitted', 'Approved', 'Denied', 'Completed')")]) @property def isOver300Hours(self): if not int(self.totalHours) or (int(self.totalHours) and int(self.totalHours) >= 300): return True return False + + @property + def isApproved(self): + return self.status in ['Approved', 'Completed'] diff --git a/app/models/note.py b/app/models/note.py index 86c108ce1..c06e022e4 100644 --- a/app/models/note.py +++ b/app/models/note.py @@ -4,6 +4,6 @@ class Note(baseModel): createdBy = ForeignKeyField(User) createdOn = DateTimeField() - noteContent = CharField() + noteContent = TextField() isPrivate = BooleanField(default=False) noteType = CharField(null=True) diff --git a/app/models/user.py b/app/models/user.py index d901d26b0..c9652f86c 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -31,9 +31,17 @@ def __init__(self,*args, **kwargs): @property def processedClassLevel(self): - if not self.rawClassLevel: - return "" - return "Graduated" if (self.hasGraduated) else self.rawClassLevel + if self.isAlumni: + return "Alumni" + return self.rawClassLevel or "Not Enrolled" + + @property + def isAlumni(self): + return self.hasGraduated or self.rawClassLevel == "Graduating" + + @property + def isCurrentlyEnrolled(self): + return self.isStudent and not self.isAlumni @property def isAdmin(self): diff --git a/app/static/css/cceMinorManagement.css b/app/static/css/cceMinorManagement.css new file mode 100644 index 000000000..347cbf333 --- /dev/null +++ b/app/static/css/cceMinorManagement.css @@ -0,0 +1,34 @@ +ul.timeline { + list-style-type: none; + position: relative; +} +ul.timeline:before { + content: ' '; + background: #d4d9df; + display: inline-block; + position: absolute; + left: 29px; + width: 2px; + height: 100%; + z-index: 400; +} +ul.timeline > li { + margin: 20px 0; + padding-left: 20px; +} +ul.timeline > li:before { + content: ' '; + background: white; + display: inline-block; + position: absolute; + border-radius: 50%; + border: 2px solid #198754; + left: 20px; + width: 20px; + height: 20px; + z-index: 400; +} + +ul.timeline > li.completed:before { + background: #198754; +} \ No newline at end of file diff --git a/app/static/css/sidebar.css b/app/static/css/sidebar.css index 0eab0ef68..46da28f3c 100644 --- a/app/static/css/sidebar.css +++ b/app/static/css/sidebar.css @@ -18,7 +18,7 @@ height: 100vh; max-height: 100vh; overflow-x: auto; - overflow-y: hidden; + overflow-y: auto; } .b-example-divider { @@ -41,3 +41,18 @@ .fw-semibold { font-weight: 600; } .lh-tight { line-height: 1.25; } + + +.col-xs-3{ + -webkit-transition: all 0.25s ease-in-out; + -moz-transition: all 0.25s ease-in-out; + -o-transition: all 0.25s ease-in-out; + transition: all 0.25s ease-in-out; +} +.sidebar_padding{ + margin-block:2px; + padding-block: 0.7rem; +} +.sidebar_padding:hover{ + background-color: #0d6dfc; +} diff --git a/app/static/css/slcNewProposal.css b/app/static/css/slcNewProposal.css index d9c1f56ce..c6529396d 100644 --- a/app/static/css/slcNewProposal.css +++ b/app/static/css/slcNewProposal.css @@ -26,4 +26,14 @@ /* Mark the steps that are finished and valid: */ .step.finish { background-color: #0d6efd; -} \ No newline at end of file +} + +@media (max-width: 767.98px) { + #proposalButtonControls { + --bs-gutter-y: 0.5rem; + } + + #proposalButtonControls .proposal-button-group { + display: contents; + } +} diff --git a/app/static/js/base.js b/app/static/js/base.js index 3ef308142..3fd047473 100644 --- a/app/static/js/base.js +++ b/app/static/js/base.js @@ -205,70 +205,101 @@ function handleFileSelection(fileInputId, single=false){ var attachedObjectContainerId = fileInputId + "Container" $(fileBoxId).after(`
`) var objectContainerId = "#" + attachedObjectContainerId - $(fileBoxId).on('change', function() { - const selectedFiles = $(fileBoxId).prop('files'); - for (let i = 0; i < selectedFiles.length; i++){ - const file = selectedFiles[i]; - if (hasUniqueFileName(file.name)){ - let fileName = (file.name.length > 25) ? file.name.slice(0,10) + '...' + file.name.slice(-10) : file.name; - let fileExtension = file.name.split(".").pop(); - let iconClass = ''; - switch(fileExtension) { - case 'jpg': - case 'png': - case 'jpeg': - iconClass = "bi-file-image"; - break - case 'pdf': - iconClass = 'bi-filetype-pdf'; - break - case 'docx': - iconClass = 'bi-filetype-docx'; - break - case 'xlsx': - iconClass = 'bi-filetype-xlsx'; - break - default: - iconClass = 'bi-file-earmark-arrow-up'; - } - let trashNum = ($(objectContainerId+ " .row").length) - var fullTrashId = "#trash" + trashNum - let fileHTML = " \ -
\ - \ -
" + fileName + "
\ -
\ -
\ - \ -
\ -
\ -
" - if (single) { - $(objectContainerId).html(fileHTML) - } - else { - $(objectContainerId).append(fileHTML) - } - $(fullTrashId).data("file", file); - $(fullTrashId).data("file-container-id", attachedObjectContainerId); - $(fullTrashId).on("click", function() { - let elementFileNum = $(this).data('filenum'); - let attachedObjectContainerId = $(this).data('file-container-id'); - $("#"+ attachedObjectContainerId + " #attachedFilesRow" + elementFileNum).remove(); - $(fileBoxId).prop('files', getSelectedFiles()); - }) - $(fileBoxId).data("file-num", $(fileBoxId).data("file-num") + 1) + + // if we have files that are already saved we can get their information from here + let filePath = $(fileBoxId).data("file-path") + let fileName = $(fileBoxId).data("file-name") + + let existingFile = { + name: fileName, + path: filePath + } + if (filePath && fileName) { + populateSelectedFiles(fileBoxId, attachedObjectContainerId, objectContainerId, single, existingFile) + } + $(fileBoxId).on('change', () => populateSelectedFiles(fileBoxId, attachedObjectContainerId, objectContainerId, single)); +} + +function populateSelectedFiles(fileBoxId, attachedObjectContainerId, objectContainerId, single, existingFile=null) { + const selectedFiles = existingFile ? [existingFile] : $(fileBoxId).prop('files'); + for (let i = 0; i < selectedFiles.length; i++){ + const file = selectedFiles[i]; + if (hasUniqueFileName(file.name)){ + let fileName = (file.name.length > 25) ? file.name.slice(0,10) + '...' + file.name.slice(-10) : file.name; + let trashNum = ($(objectContainerId+ " .row").length) + let iconClass = getIconClass(file) + let viewing = !(Boolean($("#isViewing").val())) + let fileHTML = existingFile + ? generateFileRowHTML(trashNum, iconClass, existingFile.name, existingFile.path, showTrash=viewing) + : generateFileRowHTML(trashNum, iconClass, fileName) + var fullTrashId = "#trash" + trashNum + if (single) { + $(objectContainerId).html(fileHTML) + } + else { + $(objectContainerId).append(fileHTML) + } + $(fullTrashId).data("file", file); + $(fullTrashId).data("file-container-id", attachedObjectContainerId); + $(fullTrashId).on("click", function() { + let elementFileNum = $(this).data('filenum'); + let attachedObjectContainerId = $(this).data('file-container-id'); + $("#"+ attachedObjectContainerId + " #attachedFilesRow" + elementFileNum).remove(); + $(fileBoxId).prop('files', getSelectedFiles()); + }) + $(fileBoxId).data("file-num", $(fileBoxId).data("file-num") + 1) + } + else{ + if (single){ + $(objectContainerId).html(fileHTML) } else{ - if (single){ - $(objectContainerId).html(fileHTML) - } - else{ - msgToast("File with filename '" + file.name + "' has already been added to this event") - } + msgToast("File with filename '" + file.name + "' has already been added to this event") } } + } + if (!existingFile) { $(fileBoxId).prop('files', getSelectedFiles()); - }); + } +} +function generateFileRowHTML(trashNum, iconClass, fileName, filePath=null, showTrash=true) { + return ` +
+ + ${filePath != null ? + `${fileName}` + : `
${fileName}
` + } +
+
+ +
+
+
+ ` } + +function getIconClass(file) { + let iconClass = ''; + let fileExtension = file.name.split(".").pop(); + switch(fileExtension) { + case 'jpg': + case 'png': + case 'jpeg': + iconClass = "bi-file-image"; + break + case 'pdf': + iconClass = 'bi-filetype-pdf'; + break + case 'docx': + iconClass = 'bi-filetype-docx'; + break + case 'xlsx': + iconClass = 'bi-filetype-xlsx'; + break + default: + iconClass = 'bi-file-earmark-arrow-up'; + } + return iconClass +} \ No newline at end of file diff --git a/app/static/js/bonnerManagement.js b/app/static/js/bonnerManagement.js index 19130df95..346597995 100644 --- a/app/static/js/bonnerManagement.js +++ b/app/static/js/bonnerManagement.js @@ -76,7 +76,7 @@ $(document).ready(function(){ } else { fileName = `Bonner Spreadsheet, ${Number(startingYear) - Number(noOfYears)} - ${startingYear}`; } - + $.ajax({ url: url, method: "GET", diff --git a/app/static/js/cceMinorProposalManagement.js b/app/static/js/cceMinorProposalManagement.js new file mode 100644 index 000000000..ae1ca3278 --- /dev/null +++ b/app/static/js/cceMinorProposalManagement.js @@ -0,0 +1,50 @@ +$(document).ready(function() { + $("#withdrawBtn").on("click", function(){ + updateProposalStatus('withdraw') + }) +}); + +function changeAction(element) { + const proposalId = element.id; + const proposalType = $(element).data('type'); + const proposalAction = element.value; + $('#proposalID').val(proposalId); + + if (proposalAction === "Edit") { + location = `/cceMinor/edit${proposalType.replace(/\s+/g, '')}/${proposalId}`; + } else if (proposalAction === "View") { + location = `/cceMinor/view${proposalType.replace(/\s+/g, '')}/${proposalId}`; + } else if (proposalAction === "withdraw") { + $('#withdrawModal').modal('show'); + } else { + updateProposalStatus(proposalAction.toLowerCase()); + } + + resetAllSelections(); +} + + +function resetAllSelections() { + $('.form-select').val('---'); +} + +function updateProposalStatus(action){ + // for withdrawing proposals or marking them as complete + let proposalID = $("#proposalID").val(); + let username = $("#username").val(); + + $.ajax({ + url: `/cceMinor/${action}/${username}/${proposalID}`, + type: "POST", + success: function(res){ + window.location.href = `/profile/${username}/cceMinor?tab=manageProposals`; + }, + error: function(request, status, error) { + console.log(status, error); + } + }); + + resetAllSelections(); +} + +window.changeAction = changeAction; diff --git a/app/static/js/createEvents.js b/app/static/js/createEvents.js index 1a734a73c..8e0609633 100644 --- a/app/static/js/createEvents.js +++ b/app/static/js/createEvents.js @@ -21,9 +21,9 @@ function format12to24HourTime(timeStr) { let [hours, minutes] = timePart.split(":").map(Number); if (meridian === "PM" && hours !== 12) { - hours += 12; + hours += 12; } else if (meridian === "AM" && hours === 12) { - hours = 0; // midnight + hours = 0; // midnight } // format hours and minutes to always be 2 digits @@ -33,43 +33,54 @@ function format12to24HourTime(timeStr) { return `${formattedHours}:${formattedMinutes}`; } -function calculateRepeatingEventFrequency(){ - var eventDatesAndName = {name:$("#repeatingEventsNamePicker").val(), - isRepeating: true, - startDate:$("#repeatingEventsStartDate").val(), - endDate:$("#repeatingEventsEndDate").val()} +function calculateRepeatingEventFrequency() { + var eventDatesAndName = { + name: $("#repeatingEventsNamePicker").val(), + isRepeating: true, + startDate: $("#repeatingEventsStartDate").val(), + endDate: $("#repeatingEventsEndDate").val(), + location: $("#repeatingEventsLocationPicker").val() || '' + } + $.ajax({ - type:"POST", + type: "POST", url: "/makeRepeatingEvents", //get the startDate, endDate and name as a dictionary data: eventDatesAndName, - success: function(jsonData){ + success: function (jsonData) { var generatedEvents = JSON.parse(jsonData) $("#generatedEventsTable tbody tr").remove(); - for(var event of generatedEvents){ + for (var event of generatedEvents) { loadRepeatingOfferingToModal(event) } $("#generatedEvents").removeClass("d-none"); }, - error: function(error){ - console.log(error) + error: function (error) { + displayNotification("Failed to generate events."); } }); } -function setViewForSingleOffering(){ +function setViewForSingleOffering() { $(".startDatePicker").prop('required', true); + $(".startDatePicker").show(); $("#multipleOfferingTableDiv").addClass('d-none'); - $('#eventTime, #eventDate').removeClass('d-none'); - $('#checkIsSeriesToggleContainer').addClass('col-md-6') + $("#eventLocation-main").show(); + $("#inputEventLocation-main").attr('readonly', false); + $("#inputEventLocation-main").prop('required', true); + $('#eventTime, #eventDate').show(); $('#checkIsSeriesToggleContainer').removeClass('col-md-12') + $('#checkIsSeriesToggleContainer').addClass('col-md-6') } -function setViewForSeries(){ +function setViewForSeries() { $(".startDatePicker").prop('required', false); + $(".startDatePicker").hide(); $("#multipleOfferingTableDiv").removeClass('d-none'); - $('#eventTime, #eventDate').addClass('d-none'); + $("#eventLocation-main").show(); + $("#inputEventLocation-main").prop('required', false); + $('#eventTime, #eventDate').hide(); $('#checkIsSeriesToggleContainer').removeClass('col-md-6') $('#checkIsSeriesToggleContainer').addClass('col-md-12') $("#pastDateWarningText").text("") @@ -78,13 +89,14 @@ function setViewForSeries(){ function displayNotification(message) { $('#textNotifierPadding').addClass('pt-5'); $('.invalidFeedback').text(message); - $('.invalidFeedback').css('display', 'block'); - $('.invalidFeedback').on('animationend', function() { - $('.invalidFeedback').css('display', 'none'); - $('#textNotifierPadding').removeClass('pt-5') + $('.invalidFeedback').css('display', 'block'); + $('.invalidFeedback').on('animationend', function () { + $('.invalidFeedback').css('display', 'none'); + $('#textNotifierPadding').removeClass('pt-5'); }); } + function isDateInPast(dateString, timeString) { const combineDateTime = `${dateString}T${timeString}:00`; const setDate = new Date(combineDateTime).getTime(); @@ -102,20 +114,23 @@ function initializeFlatpickr(obj) { minTime: "08:00", maxTime: "22:00", minuteIncrement: 15, - allowInput: true + allowInput: true }); } -function createOfferingModalRow({eventName=null, eventDate=null, startTime=null, endTime=null}={}){ - +function createOfferingModalRow({ eventName = null, eventDate = null, startTime = null, endTime = null, eventLocation = null } = {}) { let clonedOffering = $("#multipleOfferingEvent").clone().removeClass('d-none').removeAttr("id"); + const Name = $('#inputEventName').val(); + clonedOffering.find('.multipleOfferingNameField').val(Name); + // insert values for the newly created row - if (eventName) {clonedOffering.find('.multipleOfferingNameField').val(eventName)} - if (eventDate) {clonedOffering.find('.multipleOfferingDatePicker').val(eventDate)} - if (startTime) {clonedOffering.find('.multipleOfferingStartTime').val(startTime)} - if (endTime) {clonedOffering.find('.multipleOfferingEndTime').val(endTime)} - + if (eventName) { clonedOffering.find('.multipleOfferingNameField').val(eventName) } + if (eventDate) { clonedOffering.find('.multipleOfferingDatePicker').val(eventDate) } + if (startTime) { clonedOffering.find('.multipleOfferingStartTime').val(startTime) } + if (endTime) { clonedOffering.find('.multipleOfferingEndTime').val(endTime) } + if (eventLocation) { clonedOffering.find('.multipleOfferingLocationField').val(eventLocation) } + $("#multipleOfferingSlots").append(clonedOffering); pendingmultipleEvents.push(clonedOffering); @@ -189,6 +204,7 @@ $('#saveSeries').on('click', function(e) { enableLiveCustomValidityClearing([".multipleOfferingNameField"]) let eventOfferings = $('#multipleOfferingSlots .eventOffering'); let eventNameInputs = $('#multipleOfferingSlots .multipleOfferingNameField'); + let eventLocationInputs = $('#multipleOfferingSlots .multipleOfferingLocationField'); let datePickerInputs = $('#multipleOfferingSlots .multipleOfferingDatePicker'); let startTimeInputs = $('#multipleOfferingSlots .multipleOfferingStartTime'); let endTimeInputs = $('#multipleOfferingSlots .multipleOfferingEndTime'); @@ -220,6 +236,7 @@ $('#saveSeries').on('click', function(e) { } else { // Validate individual event offerings for non-repeating events + // Check event name fields eventNameInputs.each((index, eventNameInput) => { if (eventNameInput.value.trim() === '') { @@ -229,6 +246,18 @@ $('#saveSeries').on('click', function(e) { } else { $(eventNameInput)[0].setCustomValidity(""); } + }); + + + // Check location fields + eventLocationInputs.each((index, eventLocationInput) => { + if (eventLocationInput.value.trim() === '') { + hasErrors = true; + $(eventLocationInput)[0].setCustomValidity("Please enter an event location"); + $(eventLocationInput)[0].reportValidity(); + } else { + $(eventLocationInput)[0].setCustomValidity(""); + } }); // Check date picker fields @@ -303,24 +332,11 @@ $('#saveSeries').on('click', function(e) { // Populate the Event Name field in the main page with the entered repeating events function updateEventNameField() { - let offerings = JSON.parse($("#seriesData").val()) let isSeries = $("#checkIsRepeating").is(":checked") - - // Check if the event is weekly - if (!isSeries) { - // if not weeekly, add them to a set to remove duplicates, then put them in a string to populate the field - let names = new Set() - offerings.forEach(offering => { - names.add(offering.eventName) - }); - let offeringsText = Array.from(names).join(", ") - $('#inputEventName').val(offeringsText) - } - else { - // if weekly, take the name of the first item (which is the same for all) and take the word 'week' + if (isSeries) { let offeringText = $("#repeatingEventsNamePicker").val() $('#inputEventName').val(offeringText) - } + } } // Save the offerings from the modal to the hidden input field @@ -329,32 +345,34 @@ function saveOfferingsFromModal() { let isRepeatingStatus = $("#checkIsRepeating").is(":checked"); $("#formIsRepeating").prop("checked", isRepeatingStatus); let dataTable = isRepeatingStatus ? "#generatedEventsList" : "#multipleOfferingSlots"; - $(dataTable).children().each(function(index, element) { + $(dataTable).children().each(function (index, element) { let rowData; - if (isRepeatingStatus){ - rowData = $.map($(element).find("td"), function(td){ + if (isRepeatingStatus) { + rowData = $.map($(element).find("td"), function (td) { let input = $(td).find("input"); - if (input.length){ + if (input.length) { return input.val(); } else { return $(td).text().trim(); } - })} + }) + } else { rowData = $.map($(element).find("input"), (el) => $(el).val()); } - let startTime = isRepeatingStatus ? $("#repeatingEventsStartTime").val() : rowData[2] - let endTime = isRepeatingStatus ? $("#repeatingEventsEndTime").val() : rowData[3] + let startTime = isRepeatingStatus ? $("#repeatingEventsStartTime").val() : rowData[3] + let endTime = isRepeatingStatus ? $("#repeatingEventsEndTime").val() : rowData[4] if (navigator.userAgent.indexOf("Chrome") == -1) { - startTime = format12to24HourTime(startTime) - endTime = format12to24HourTime(endTime) + startTime = format12to24HourTime(startTime) + endTime = format12to24HourTime(endTime) } offerings.push({ - eventName: rowData[0], - eventDate: rowData[1], - startTime: startTime, - endTime: endTime, + eventName: rowData[0], + eventLocation: isRepeatingStatus ? rowData[2] : rowData[1], + eventDate: isRepeatingStatus ? rowData[1] : rowData[2], + startTime: startTime, + endTime: endTime, }) }); @@ -364,29 +382,36 @@ function saveOfferingsFromModal() { $("#seriesData").val(offeringsJson); } -function loadOfferingsToModal(){ +function loadOfferingsToModal() { let offerings = JSON.parse($("#seriesData").val()) - if (offerings.length < 1) {return;} + if (offerings.length < 1) { return; } let isRepeatingStatus = $("#checkIsRepeating").is(":checked"); - if (isRepeatingStatus) {$("#generatedEvents").removeClass("d-none"); $("#generatedEventsTable tbody tr").remove();}; - offerings.forEach((offering, i) =>{ - if (isRepeatingStatus){ + let mainLocation = $('#inputEventLocation-main').val(); + if (isRepeatingStatus) { $("#generatedEvents").removeClass("d-none"); $("#generatedEventsTable tbody tr").remove(); }; + offerings.forEach((offering, i) => { + if (isRepeatingStatus) { loadRepeatingOfferingToModal(offering); } else { - let newOfferingModalRow = createOfferingModalRow(offering); + let newOfferingModalRow = createOfferingModalRow({ + ...offering, + eventLocation: offering.eventLocation || mainLocation + }); //stripes odd event sections in event modal - newOfferingModalRow.css('background-color', i % 2 ?'#f2f2f2':'#fff'); - }}) + newOfferingModalRow.css('background-color', i % 2 ? '#f2f2f2' : '#fff'); + } + }); } function loadRepeatingOfferingToModal(offering){ var seriesTable = $("#generatedEventsTable"); - var eventDate = new Date(offering.date || offering.eventDate).toLocaleDateString(); + var eventDate = formatDate(offering.date || offering.eventDate); + seriesTable.append( "" + - "" + (offering.name || offering.eventName) + "" + + "" + (offering.name || offering.eventName) + "" + "" + eventDate + "" + + "" + (offering.eventLocation || offering.location) + "" + "
" + "" ); @@ -396,19 +421,24 @@ function loadRepeatingOfferingToModal(offering){ function updateOfferingsTable() { let offerings = JSON.parse($("#seriesData").val()) var offeringsTable = $("#offeringsTable"); + + // Sorting the offerings by data and time in case user inputs in an unsorted manner + offerings.sort((a, b) => new Date(a.eventDate) - new Date(b.eventDate) || a.startTime.localeCompare(b.startTime)); + offeringsTable.find("tbody tr").remove(); // Clear existing rows - offerings.forEach(function(offering){ + offerings.forEach(function (offering) { //format to 12hr time for display var formattedEventDate = formatDate(offering.eventDate); var startTime = format24to12HourTime(offering.startTime); var endTime = format24to12HourTime(offering.endTime); offeringsTable.append(`` + - "" + offering.eventName + "" + - "" + formattedEventDate + "" + - "" + startTime + "" + - "" + endTime + "" + - "" - ); + "" + offering.eventName + "" + + "" + formattedEventDate + "" + + "" + startTime + "" + + "" + endTime + "" + + "" + (offering.eventLocation || offering.location || "") + "" + + "" + ); }); //recalculate the save button @@ -417,12 +447,12 @@ function updateOfferingsTable() { //visual date formatting for multi-event table function formatDate(originalDate) { - var dateObj = new Date(originalDate); - // dateObj.setUTCHours(0, 0, 0, 0); // set the timezone + var dateObj = new Date(originalDate); // because there is no time in the string, Date assumes UTC - var month = dateObj.toLocaleString('default', { month: 'short' }); + var month = dateObj.toLocaleString('default', { month: 'short', timeZone: 'UTC' }); var day = dateObj.getUTCDate(); var year = dateObj.getUTCFullYear(); + return month + " " + day + ", " + year; } @@ -512,14 +542,21 @@ function checkValidation() { } } - - /* * Run when the webpage is ready for javascript */ -$(document).ready(function() { +$(document).ready(function () { var isEditPage = (window.location.pathname == '/event/' + $('#newEventID').val() + '/edit') + + // This is to prevent the server from being overloaded with requests while the user is typing + let debounceTimer; + $("#repeatingEventsNamePicker, #repeatingEventsLocationPicker").on("input", function () { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { + handleRepeatingEventsChange(); + }, 3000); + }); //makes sure bonners toggle will stay on between event pages if (isEditPage) { if ($("#checkBonners")) { @@ -530,7 +567,7 @@ $(document).ready(function() { // don't use a minimum if we are editing an existing event var minDate = new Date() if (isEditPage) { - minDate = null; + minDate = null; } handleFileSelection("attachmentObject") @@ -579,11 +616,12 @@ $("#cancelEvent").on('click', function (event) { }); updateOfferingsTable(); - - if ($("#checkIsSeries").is(":checked")){ + + if ($("#checkIsSeries").is(":checked")) { setViewForSeries(); } - + + let modalOpenedByEditButton = false; //#checkIsRepeating, #checkIsSeries are attributes for the toggle buttons on create event page $("#checkIsSeries, #edit_modal").click(function(event) { @@ -596,15 +634,25 @@ $("#cancelEvent").on('click', function (event) { modalOpenedByEditButton = ($(this).attr('id') === 'edit_modal'); if (isSeries) { + if ($('#inputEventName').val().trim() == '') { + $('#checkIsSeries').prop('checked', false); + $('#inputEventName').focus(); + return; + } + if ($('#inputEventLocation-main').val().trim() == '') { + $('#checkIsSeries').prop('checked', false); + $('#inputEventLocation-main').focus(); + return; + } setViewForSeries(); loadOfferingsToModal(); $('#modalSeries').modal('show'); - - // Disable single event name field - $('#inputEventName').prop('readonly', true) - $('#inputEventName').val('') + + // Disable single event name and location fields + $('#inputEventName').prop('readonly', true); + $('#inputEventLocation-main').prop('readonly', true); } else { - setViewForSingleOffering() + setViewForSingleOffering(); $('#multipleOfferingTableDiv').addClass('d-none'); // Enable single event name field $('#inputEventName').prop('readonly', false) @@ -613,38 +661,49 @@ $("#cancelEvent").on('click', function (event) { }); //untoggles the button when the modal cancel or close button is clicked - $("#cancelModalPreview, #multipleOfferingXbutton").click(function(){ + $("#cancelModalPreview, #multipleOfferingXbutton").click(function () { if (modalOpenedByEditButton == false) { $('#modalSeries').modal('hide'); $("#checkIsSeries").prop('checked', false); - setViewForSingleOffering() + setViewForSingleOffering(); } - pendingmultipleEvents.forEach(function(element){ + pendingmultipleEvents.forEach(function (element) { element.remove(); }); let isSeries = $("#checkIsSeries").is(":checked") - if (!isSeries){ + if (!isSeries) { // Enable single event name field $('#inputEventName').prop('readonly', false) $('#inputEventName').prop('placeholder', 'Enter event name') checkIfDateInPast(); + $('#inputEventLocation-main').prop('readonly', false) + $('#inputEventLocation-main').prop('placeholder', 'Enter event location') } }); - $("#checkIsRepeating").change(function() { + $("#checkIsRepeating").change(function () { if ($(this).is(':checked')) { + $("#repeatingEventsNamePicker").val($("#inputEventName").val()); + $("#repeatingEventsLocationPicker").val($("#inputEventLocation-main").val()); + $("#repeatingEventsStartDate").val($("#startDatePicker-mainOnly").val()); + $("#repeatingEventsStartTime").val($("#startTime-main").val()); + $("#repeatingEventsEndTime").val($("#endTime-main").val()); $('.addMultipleOfferingEvent').hide(); $("#repeatingEventsDiv").removeClass('d-none'); $("#multipleOfferingSlots").children().remove(); $("#multipleOfferingSlots").addClass('d-none'); } else { - $('.addMultipleOfferingEvent').show(); + $('.addMultipleOfferingEvent').show(); $("#repeatingEventsDiv").addClass('d-none'); $("#multipleOfferingSlots").removeClass('d-none'); } }); - $("#repeatingEventsDiv").change(handleRepeatingEventsChange) + $("#repeatingEventsNamePicker, " + "#repeatingEventsLocationPicker").on("input", handleRepeatingEventsChange); + $("#repeatingEventsStartDate, " + + "#repeatingEventsEndDate, " + + "#repeatingEventsStartTime, " + + "#repeatingEventsEndTime").on("change", handleRepeatingEventsChange); // this handels start date, end date, last event date, start time, and end time function handleRepeatingEventsChange() { if (!verifyRepeatingFields()) { @@ -661,13 +720,13 @@ $("#cancelEvent").on('click', function (event) { if (endDate <= startDate) { displayNotification("The end date must be after the start date."); - table.each(function(){$(this).remove()}) + table.each(function () { $(this).remove() }) $("#generatedEvents").addClass('d-none'); return; } - if (endTime <= startTime){ + if (endTime <= startTime) { displayNotification("The end time must be after the start time."); - table.each(function(){$(this).remove()}) + table.each(function () { $(this).remove() }) $("#generatedEvents").addClass('d-none'); return; } @@ -677,7 +736,7 @@ $("#cancelEvent").on('click', function (event) { } } - $(document).on("click", ".deleteGeneratedEvent, .deleteMultipleOffering", function() { + $(document).on("click", ".deleteGeneratedEvent, .deleteMultipleOffering", function () { let attachedRow = $(this).closest(".eventOffering") attachedRow.animate({ opacity: 0, @@ -687,70 +746,76 @@ $("#cancelEvent").on('click', function (event) { msgToast("Deletion info", "You have successfully deleted a series of events") }); }); - + /*cloning the div with ID multipleOfferingEvent and cloning, changing the ID of each clone going up by 1. This also changes the ID of the deleteMultipleOffering so that when the trash icon is clicked, that specific row will be deleted*/ - $(".addMultipleOfferingEvent").click(createOfferingModalRow) + $(".addMultipleOfferingEvent").click(function () { + // Get the current value from the main location input and the date input + let mainLocation = $("#inputEventLocation-main").val(); + let existingRows = $("#multipleOfferingSlots .eventOffering").length; + let mainDate = existingRows === 0 ? $("#startDatePicker-mainOnly").val() : null; + let mainTime = $("#startTime-main").val(); + let endTime = $("#endTime-main").val(); + createOfferingModalRow({ eventLocation: mainLocation, eventDate: mainDate, startTime: mainTime, endTime: endTime }); +}); - var minDate = new Date('10/25/1999') - $("#startDatePicker-main").datepicker("option", "minDate", minDate) + var minDate = new Date('10/25/1999') + $("#startDatePicker-main").datepicker("option", "minDate", minDate) // This converts the time to 24 hour format in case it is in 12 hour format (like in Firefox) -function handleTimeFormatting(timeArray){ - let time = timeArray[0] - let timeSuffix = timeArray[1] // looks for AM or PM in time - let [hours , min] = time. split(':') - - if (timeArray.length === 2) { - hours = parseInt(hours, 10) - if (timeSuffix === 'PM' && hours !== 12) { - hours += 12; - } else if (timeSuffix === 'AM' && hours === 12) { - hours = 0; + function handleTimeFormatting(timeArray) { + let time = timeArray[0] + let timeSuffix = timeArray[1] // looks for AM or PM in time + let [hours, min] = time.split(':') + + if (timeArray.length === 2) { + hours = parseInt(hours, 10) + if (timeSuffix === 'PM' && hours !== 12) { + hours += 12; + } else if (timeSuffix === 'AM' && hours === 12) { + hours = 0; + } + const hoursStr = hours.toString().padStart(2, '0'); + return [hoursStr, min] } - const hoursStr = hours.toString().padStart(2, '0'); - return [hoursStr, min] + return [hours, min] } - return [hours, min] -} function checkIfDateInPast() { const [month, day, year] = $("#startDatePicker-main").val().split('/') - const startTimeArray = $("#startTime-main").val().split(' ') + const startTimeArray = $("#startTime-main").val().split(' ') const [startHour, startMin] = handleTimeFormatting(startTimeArray) const endTimeArray = $('#endTime-main').val().split(' ') - const [endHour, endMin] = handleTimeFormatting (endTimeArray) - let startDateSelected =new Date(+year, +month - 1, +day, +startHour, +startMin); + const [endHour, endMin] = handleTimeFormatting(endTimeArray) + let startDateSelected = new Date(+year, +month - 1, +day, +startHour, +startMin); let endDateSelected = new Date(+year, +month - 1, +day, +endHour, +endMin) let now = new Date() - + if (startDateSelected < now && endDateSelected > now) { $("#pastDateWarningText").text("This event is currently in progress!") - } - else if (startDateSelected < now && endDateSelected < now) { + } else if (startDateSelected < now && endDateSelected < now) { $("#pastDateWarningText").text("This event is in the past!") - } - else + }else{ $("#pastDateWarningText").text("") } - $("#startDatePicker-main").on("change", function() { + $("#startDatePicker-main").on("change", function () { checkIfDateInPast() }) - $("#startTime-main").on("change", function() { + $("#startTime-main").on("change", function () { checkIfDateInPast() }) - - $("#endTime-main").on("change", function() { + + $("#endTime-main").on("change", function () { checkIfDateInPast() }) // everything except Chrome if (navigator.userAgent.indexOf("Chrome") == -1) { initializeFlatpickr(".flatpickr") - + $(".timepicker").prop("type", "text"); $(".timeIcons").prop("hidden", false); @@ -763,12 +828,13 @@ function handleTimeFormatting(timeArray){ $(".timepicker").prop("type", "time"); $(".timeIcons").prop("hidden", true); } + } if ($(".datePicker").is("readonly")) { $(".datePicker").datepicker("option", "disabled", true); } - $(".readonly").on('keydown paste', function(e) { + $(".readonly").on('keydown paste', function (e) { if (e.keyCode != 9) // ignore tab e.preventDefault(); }); @@ -817,8 +883,4 @@ function handleTimeFormatting(timeArray){ }); setCharacterLimit($("#inputCharacters"), "#remainingCharacters"); - -}); - - - + }); \ No newline at end of file diff --git a/app/static/js/eventList.js b/app/static/js/eventList.js index e776503f5..27379400c 100644 --- a/app/static/js/eventList.js +++ b/app/static/js/eventList.js @@ -87,16 +87,39 @@ function updateIndicatorCounts(isChecked){ }, success: function(eventsCount) { const volunteerOpportunitiesCount = Number(eventsCount.volunteerOpportunitiesCount); + const upcomingVolunteerCount = Number(eventsCount.countUpcomingVolunteerOpportunitiesCount); const trainingEventsCount = Number(eventsCount.trainingEventsCount); const engagementEventsCount = Number(eventsCount.engagementEventsCount); const bonnerEventsCount = Number(eventsCount.bonnerEventsCount); const celtsLaborCount = Number(eventsCount.celtsLaborCount); const toggleStatus = eventsCount.toggleStatus; - $("#viewPastEventsToggle").prop(toggleStatus, true); + $("#viewPastEventsToggle").prop("checked", toggleStatus === "checked"); + + // Update tab labels with event counts: + // - When toggle is ON, show total volunteer opportunities (upcoming + past) + // - When toggle is OFF, show upcoming volunteer opportunities only + // - For all tabs, show counts only if greater than zero; otherwise show the label without a count - // use ternary operators to populate the tab with a number if there are events, and clear the count if there are none - volunteerOpportunitiesCount > 0 ? $("#volunteerOpportunities").html(`Volunteer Opportunities (${volunteerOpportunitiesCount})`) : $("#volunteerOpportunities").html(`Volunteer Opportunities`) + if (toggleStatus === "checked") { + // Toggle ON: show total (upcoming + past) + if (volunteerOpportunitiesCount > 0) { + $("#volunteerOpportunities").html( + `Volunteer Opportunities (${volunteerOpportunitiesCount})` + ); + } else { + $("#volunteerOpportunities").html(`Volunteer Opportunities`); + } + } else { + // Toggle OFF: show upcoming only + if (upcomingVolunteerCount > 0) { + $("#volunteerOpportunities").html( + `Volunteer Opportunities (${upcomingVolunteerCount})` + ); + } else { + $("#volunteerOpportunities").html(`Volunteer Opportunities`); + } + } trainingEventsCount > 0 ? $("#trainingEvents").html(`Trainings (${trainingEventsCount})`) : $("#trainingEvents").html(`Trainings`) engagementEventsCount > 0 ? $("#engagementEvents").html(`Education and Engagement (${engagementEventsCount})`) : $("#engagementEvents").html('Education and Engagement') bonnerEventsCount > 0 ? $("#bonnerScholarsEvents").html(`Bonner Scholars (${bonnerEventsCount})`) : $("#bonnerScholarsEvents").html(`Bonner Scholars`) diff --git a/app/static/js/graduationManagement.js b/app/static/js/graduationManagement.js index a09dd9212..27a1edb6a 100644 --- a/app/static/js/graduationManagement.js +++ b/app/static/js/graduationManagement.js @@ -18,8 +18,7 @@ $(document).ready(function() { return true; }); - $('.graduated-checkbox').not('.hasHandler').addClass("hasHandler") - $('.graduated-checkbox').change(checkboxClickHandler); + $(document).on('change', '.graduated-checkbox', checkboxClickHandler); initializePage() @@ -43,22 +42,27 @@ $(document).ready(function() { }) + function getRowStatus(row) { + return $(row).data('status'); + } + function filterTable(dataField, expectedValue) { gradStudentsTable.rows().every(function() { - var hasGraduated = $(this.node()).find('input[type="checkbox"]').is(':checked'); - if (!showGraduatedStudents() && hasGraduated) { - $(this.node()).addClass('hidden') - return + const row = this.node(); + const status = getRowStatus(row); + if (!showGraduatedStudents() && status === 'alumni') { + $(row).addClass('hidden'); + return; } - var data = $(this.node()).data(dataField); + const data = $(row).data(dataField); if (data === expectedValue) { - $(this.node()).removeClass('hidden') + $(row).removeClass('hidden'); } else { - $(this.node()).addClass('hidden') + $(row).addClass('hidden'); } }); - redrawTable() + redrawTable(); } function handleBonnerFilterChange(cohortYear, buttonText) { @@ -94,10 +98,10 @@ $(document).ready(function() { gradStudentsTable.search('').draw(); gradStudentsTable.rows().every(function() { - var hasGraduated = $(this.node()).find('input[type="checkbox"]').is(':checked'); - if (!showGraduatedStudents() && hasGraduated) { - $(this.node()).addClass('hidden') - return + const status = getRowStatus(this.node()); + if (!showGraduatedStudents() && status === 'alumni') { + $(this.node()).addClass('hidden'); + return; } $(this.node()).removeClass('hidden'); }); @@ -118,9 +122,7 @@ $(document).ready(function() { } function redrawTable() { - gradStudentsTable.draw(); - $('.graduated-checkbox').not('.hasHandler').change(checkboxClickHandler); - $('.graduated-checkbox').not('.hasHandler').addClass("hasHandler") + gradStudentsTable.draw(); } function checkboxClickHandler() { @@ -132,9 +134,20 @@ $(document).ready(function() { data: {status: hasGraduated ? 1 : 0}, url: `/${username}/setGraduationStatus`, success: function(response) { - initializePage() msgFlash(`Saved graduation status for ${username}.`, "success", 1000) - $(`#${username}ClassLevel`).html(hasGraduated ? "Graduated" : "Senior") + const row = $(`tr[data-username="${username}"]`); + if (hasGraduated) { + row.data('status', 'alumni'); + $(`#${username}ClassLevel`).text("Alumni"); + if (!showGraduatedStudents()) { + row.addClass('hidden'); + } + } else { + row.data('status', 'enrolled'); + $(`#${username}ClassLevel`).text("Senior"); + row.removeClass('hidden'); + } + gradStudentsTable.draw(false); }, error: function(status, error) { console.error("Error updating graduation status:", error); diff --git a/app/static/js/manageVolunteers.js b/app/static/js/manageVolunteers.js index b0f7e7814..409125c85 100644 --- a/app/static/js/manageVolunteers.js +++ b/app/static/js/manageVolunteers.js @@ -160,21 +160,23 @@ $(document).ready(function() { }); }); - - $(".attendanceCheck").on("change", function() { - let username = this.name.substring(9) //get everything after the 9th character; - let inputFieldID = `inputHours_${username}` +$(".attendanceCheck").on("change", function() { + // find checkbox/hours row + let $row = $(this).closest("tr"); + let $hoursInput = $row.find("input[name^='inputHours_']"); + let eventLength = $("#eventLength").text(); if (this.checked) { - $(`#${inputFieldID}`).prop('disabled', false); - let eventLength = $("#eventLength").text(); - $(`#${inputFieldID}`).val(eventLength); - + $hoursInput.prop('disabled', false); + if (!$hoursInput.val()) { + $hoursInput.val(eventLength); // pre-fill with event length if empty + } } else { - $(`#${inputFieldID}`).prop('disabled', true); - $(`#${inputFieldID}`).val(null); + // clear value, diable input + $hoursInput.prop('disabled', true); + $hoursInput.val(null); } - }); +}); $("#selectAllVolunteers").click(function(){ $("#addVolunteerModal input[type=checkbox]").prop('checked', true); diff --git a/app/static/js/minorAdminPage.js b/app/static/js/minorAdminPage.js index ac238e5a3..2cd049b7c 100644 --- a/app/static/js/minorAdminPage.js +++ b/app/static/js/minorAdminPage.js @@ -43,9 +43,9 @@ $('.remove_minor_candidate').on('click', function() { emailMinorCandidates($("#declaredStudentEmails").val()) }); - $('#emailAll').on('click', emailAll); - - $(".updateMinorInterestButton").on("click", function(e){ + $('#emailAll').on('click', emailAll); + // this was done to fix a bug where the declare student button would not work on the second page. + $(document).on("click", ".updateMinorInterestButton", function(e){ e.preventDefault(); let interestForm = $("#updateMinorInterestForm"); let url = $(this).data("url"); diff --git a/app/static/js/minorProfilePage.js b/app/static/js/minorProfilePage.js index 8792c70cc..3c77e4375 100644 --- a/app/static/js/minorProfilePage.js +++ b/app/static/js/minorProfilePage.js @@ -2,45 +2,6 @@ import { validateEmail } from "./emailValidation.mjs"; $(document).ready(function() { $("#supervisorEmail").on('input', validateEmail); - $("#withdrawBtn").on("click", withdrawProposal); - -function changeAction(action){ - let proposalID = action.id; - let proposalAction = action.value; - // decides what to do based on selection - if (proposalAction == "Withdraw"){ - $('#proposalID').val(proposalID); - $('#withdrawModal').modal('show'); - - } - resetAllSelections() - } - - - function resetAllSelections() { - $('.form-select').val('---'); - } - - - function withdrawProposal(){ - // uses hidden label to withdraw course - let proposalID = $("#proposalID").val(); - let username = $("#username").val() - $.ajax({ - url: `/cceMinor/withdraw/${username}/${proposalID}`, - type: "POST", - success: function(s){ - window.location.href = `/profile/${username}/cceMinor?tab=manageProposals` - }, - error: function(request, status, error) { - console.log(status, error); - } - }) - resetAllSelections() - }; - - - window.changeAction = changeAction; $('input.phone-input').inputmask('(999)-999-9999') $('input.phone-input').on('input', function(){ @@ -56,6 +17,38 @@ function changeAction(action){ } }) + handleFileSelection("supervisorAttachment", true) + + $('.bi-trash').on('click', function () { + $('#deleteAttachmentFlag').val('true'); + $('#supervisorAttachment').val(''); + }); + + $('#supervisorAttachment').on('change', function () { + // if a new attachment is uploaded after a previously deleted one, we want to reset the hidden delete input field + if ($(this).val()) { + $('#deleteAttachmentFlag').val('false'); + } + }); + + $('.submit-proposal').on('click', function(e) { + const proposal = $('#proposalForm')[0]; + const experienceType = $('#proposalExperienceType').val() + + let customValidity = experienceType === "Other Engagement" + ? true + : validateContentAreas(); + + if (!customValidity || !proposal.checkValidity()){ + e.preventDefault(); + proposal.reportValidity(); + }}); + + $('#exitButton').on('click', function() { + let username = $("#username").val() + window.location.href = `/profile/${username}/cceMinor?tab=manageProposals` + }) + // ************** SUSTAINED COMMUNITY ENGAGEMENTS ************** // $('.engagement-row').on("click", function() { showEngagementInformation($(this).data('engagement-data')); @@ -72,28 +65,8 @@ function changeAction(action){ // ************** SUMMER EXPERIENCE ************** // $('#hoursBelow300Container').hide() - $('#otherExperienceDescription').hide() - - $('#summerExperienceForm').on('submit', function(event) { - event.preventDefault(); - var formData = new FormData(this); - var actionUrl = $(this).attr('action'); - let username = $("#username").val() - - $.ajax({ - url: actionUrl, - type: 'POST', - data: formData, - contentType: false, - processData: false, - success: function(response) { - window.location.href = `/profile/${username}/cceMinor?tab=manageProposals` - }, - error: function(xhr, status, error) { - console.error('Error:', error); - } - }); - }); + toggleUnder300HoursTextarea() + toggleOtherExperienceTextarea() $("input[name='experienceHoursOver300']").on("change", function() { toggleUnder300HoursTextarea(); @@ -132,26 +105,6 @@ function changeAction(action){ // ************** END SUMMER EXPERIENCE ************** // // ************** OTHER ENGAGEMENT ************** // - $('#otherEngagementForm').on('submit', function(event) { - event.preventDefault(); - var formData = new FormData(this); - var actionUrl = $(this).attr('action'); - let username = $("#username").val() - $.ajax({ - url: actionUrl, - type: 'POST', - data: formData, - contentType: false, - processData: false, - success: function(response) { - window.location.href = `/profile/${username}/cceMinor?tab=manageProposals` - }, - error: function(xhr, status, error) { - console.error('Error:', error); - } - }); - }); - $("input[name='experienceType']").on("change", function() { toggleOtherExperienceTextarea(); }); @@ -219,6 +172,19 @@ function showEngagementInformation(engagementInfoDict) { }); } +function validateContentAreas(){ + // custom validation for contentAreas checkboxes (summer exp) + const contentAreaCheckboxes = $('input[name="contentArea"]') + contentAreaCheckboxes[0].setCustomValidity('') + const isChecked = (contentAreaCheckboxes.filter(':checked').length) > 0; + if (!isChecked){ + contentAreaCheckboxes[0].setCustomValidity('Select at least one option') + contentAreaCheckboxes[0].reportValidity() + return false; + } + return true; +} + function toggleEngagementCredit(isChecked, engagementData, checkbox){ engagementData['username'] = $("#username").val(); @@ -243,12 +209,15 @@ function toggleEngagementCredit(isChecked, engagementData, checkbox){ } function toggleUnder300HoursTextarea() { - var yesRadio = $('#yes300hours'); + var noRadio = $('#no300hours'); var conditionalTextBox = $('#hoursBelow300Container'); - if (yesRadio.is(':checked')) { - conditionalTextBox.hide() + if (noRadio.is(':checked')) { + conditionalTextBox.show(); } else { - conditionalTextBox.show() + conditionalTextBox.hide(); + if ($('#yes300hours').is(':checked')) { + $('#totalHours').val(300); + } } } diff --git a/app/static/js/slcManagement.js b/app/static/js/slcManagement.js index b62f72784..5326ae3ee 100644 --- a/app/static/js/slcManagement.js +++ b/app/static/js/slcManagement.js @@ -125,7 +125,7 @@ function renew(){ location = '/serviceLearning/editProposal/' + newID; }, error: function(request, status, error) { - console.log(status,error); + location.reload(); } }) resetAllSelections() @@ -140,12 +140,8 @@ function withdraw(){ location.reload(); }, error: function(request, status, error) { - console.log(status,error); - + location.reload(); }, - done: function () { - $('#' + courseID).val('---'); - } }); }; diff --git a/app/static/js/slcNewProposal.js b/app/static/js/slcNewProposal.js index 0c94561e3..147334ffc 100644 --- a/app/static/js/slcNewProposal.js +++ b/app/static/js/slcNewProposal.js @@ -183,7 +183,7 @@ function disableInput() { $("#slcQuestionSix").replaceWith( "
    " + $( "#slcQuestionSix" ).text() + "
" ); $(".view").prop("disabled", true); $("#syllabusUploadButton").prop("disabled", true); - $("#submitAndApproveButton").hide(); + $("#submitAndApproveButtonWrapper").addClass("d-none"); $(".editButton").hide() $(".removeButton").hide() $(".slcQuestionWordCounter").replaceWith(" "); @@ -195,6 +195,7 @@ function readOnly() { return window.location.href.includes("view"); } + function fixStepIndicator(navigateTab) { // This function updates the active step indicator let steps = $(".step"); @@ -230,51 +231,51 @@ function showTab(currentTab) { switch(currentTab) { case 0: // First page - $("#cancelButton").show(); - $("#previousButton").hide(); - $("#submitAndApproveButton").hide(); + $("#cancelButtonWrapper").removeClass("d-none"); + $("#previousButtonWrapper").addClass("d-none"); + $("#submitAndApproveButtonWrapper").addClass("d-none"); $("#nextButton").text("Next"); - $("#nextButton").show(); - $("#saveContinue").hide(); - $("#exitButton").hide() - $("#saveExit").show(); + $("#nextButtonWrapper").removeClass("d-none"); + $("#saveContinueWrapper").addClass("d-none"); + $("#exitButtonWrapper").addClass("d-none") + $("#saveExitWrapper").removeClass("d-none"); if(readOnly()) { - $("#saveExit").hide(); - $("#exitButton").show() + $("#saveExitWrapper").addClass("d-none"); + $("#exitButtonWrapper").removeClass("d-none") } break; case 1: // Second page - $("#cancelButton").hide(); - $("#previousButton").show(); - $("#submitAndApproveButton").hide(); - $("#nextButton").hide(); - $("#saveContinue").show(); + $("#cancelButtonWrapper").addClass("d-none"); + $("#previousButtonWrapper").removeClass("d-none"); + $("#submitAndApproveButtonWrapper").addClass("d-none"); + $("#nextButtonWrapper").addClass("d-none"); + $("#saveContinueWrapper").removeClass("d-none"); $("#saveContinue").text("Next"); - $("#saveExit").show() - $("#exitButton").hide() + $("#saveExitWrapper").removeClass("d-none") + $("#exitButtonWrapper").addClass("d-none") if(readOnly()) { - $("#nextButton").show(); - $("#saveContinue").hide(); - $("#saveExit").hide() + $("#nextButtonWrapper").removeClass("d-none"); + $("#saveContinueWrapper").addClass("d-none"); + $("#saveExitWrapper").addClass("d-none") $(".removeAttachment").hide() - $("#exitButton").show() + $("#exitButtonWrapper").removeClass("d-none") } break; case 2: // Third page - $("#cancelButton").hide(); - $("#previousButton").show(); - $("#submitAndApproveButton").show(); + $("#cancelButtonWrapper").addClass("d-none"); + $("#previousButtonWrapper").removeClass("d-none"); + $("#submitAndApproveButtonWrapper").removeClass("d-none"); $("#nextButton").text("Submit Proposal"); - $("#nextButton").show(); - $("#saveContinue").hide(); - $("#exitButton").hide() - $("#saveExit").show() + $("#nextButtonWrapper").removeClass("d-none"); + $("#saveContinueWrapper").addClass("d-none"); + $("#exitButtonWrapper").addClass("d-none") + $("#saveExitWrapper").removeClass("d-none") if(readOnly()) { $("#nextButton").text("Next"); - $("#nextButton").hide(); - $("#saveExit").hide(); - $("#submitAndApproveButton").hide(); - $("#exitButton").show() + $("#nextButtonWrapper").addClass("d-none"); + $("#saveExitWrapper").addClass("d-none"); + $("#submitAndApproveButtonWrapper").addClass("d-none"); + $("#exitButtonWrapper").removeClass("d-none") } break; } diff --git a/app/static/js/userProfile.js b/app/static/js/userProfile.js index 7fe6a2c8b..61af76787 100644 --- a/app/static/js/userProfile.js +++ b/app/static/js/userProfile.js @@ -176,6 +176,9 @@ $(document).ready(function(){ $("#addNoteButton").click(function() { bonnerNoteOff() + $("#addNoteTextArea").val('') + $("#notesSaveButton").data('mode', 'add') + $("#notesSaveButton").data('noteid', null) $("#noteModal").modal("toggle") }); @@ -191,6 +194,9 @@ $(document).ready(function(){ $("#addBonnerNoteButton").click(function() { bonnerNoteOn() + $("#addNoteTextArea").val('') + $("#notesSaveButton").data('mode', 'add') + $("#notesSaveButton").data('noteid', null) $("#noteModal").modal("toggle"); }); @@ -199,6 +205,17 @@ $(document).ready(function(){ event.preventDefault() let username = $("#notesSaveButton").data('username') let isBonner = $("#bonnerInput").is(":checked") + let mode = $("#notesSaveButton").data('mode') + let noteid = $("#notesSaveButton").data('noteid') + + // If we're editing, delete the old note first + if (mode === 'edit') { + $.ajax({ + method: "POST", + url: "/" + username + "/deleteNote", + data: { "id": noteid } + }) + } $.ajax({ method: "POST", url: "/profile/addNote", @@ -218,6 +235,13 @@ $(document).ready(function(){ }); $(".deleteNoteButton").click(function() { + $("#confirmDeleteNote").data('username', $(this).data('username')) + $("#confirmDeleteNote").data('noteid', $(this).data('noteid')) + $("#deleteNoteWarning").modal("show") + + }); + + $("#confirmDeleteNote").click(function() { let username = $(this).data('username') let noteid = $(this).data('noteid') $.ajax({ @@ -225,11 +249,44 @@ $(document).ready(function(){ url: "/" + username + "/deleteNote", data: {"id": noteid}, success: function(response) { + msgFlash("Successfully deleted note", "success", 1300, true) reloadWithAccordion("notes") } }); }); + + $(".editNoteButton").click(function() { + let noteText = $(this).data('notetext') + let visibility = $(this).data('visibility') + let isBonner = $(this).data('bonner') + let noteid = $(this).data('noteid') + + + $("#addNoteTextArea").val(noteText) + $("#noteDropdown").val(visibility) + + if (isBonner === 'yes') { + bonnerNoteOn() + } else { + bonnerNoteOff() + } + + $("#notesSaveButton").data('noteid', $(this).data('noteid')) + $("#notesSaveButton").data('mode', 'edit') + + + $("#noteModal").modal("toggle") +}); + $.ajax({ + method: "POST", + url: "/" + username + "/editNote", + data: {"id": noteid}, + success: function(response) { + reloadWithAccordion("notes") + } + }); + }); /* * Background Check Functionality */ @@ -355,8 +412,8 @@ $(document).ready(function(){ typingTimer = setTimeout(saveDiet, saveInterval); }); }); + // end document.ready() -}); // end document.ready() // Update program manager status function updateManagers(el, volunteerUsername ) { diff --git a/app/static/js/volunteerDetails.js b/app/static/js/volunteerDetails.js index 1c53fe330..e151e2a83 100644 --- a/app/static/js/volunteerDetails.js +++ b/app/static/js/volunteerDetails.js @@ -5,6 +5,15 @@ $(document).ready(function () { }).get(); users = new Set(users); users = [... users] + const columnMap = { + phoneSelect: 1, + emailSelect: 2, + statusSelect: 3, + dietRestrictionSelect: 4, + emergencyContactSelect: 5, + insuranceSelect: 6, + }; + $("#tableCardToggle").on('click', function () { $("#volunteerInformationCardToPrint").toggle() $("#volunteerInformationTableToPrint_wrapper").toggle() @@ -21,73 +30,66 @@ $(document).ready(function () { $(".displayCheckbox").on('change', function () { getCheckBoxes() }) + $.fn.dataTable.ext.search.push(function (settings, data, dataIndex) { + if (settings.nTable.id !== 'volunteerInformationTableToPrint') { + return true; + } + const status = data[3].toLowerCase(); + if (status === 'attended' && !$('#attendedSelect').is(':checked')) return false; + if (status === 'rsvp' && !$('#rsvpSelect').is(':checked')) return false; + if (status === 'waitlist' && !$('#waitlistSelect').is(':checked')) return false; + return true; + }); function hideDuplicateVolunteers() { - let allEntries = $(".volunteerInfoEntries") - let shownUsers = [] - for (let i = 0; i < allEntries.length; i++) { - let currentEntry = $(allEntries[i]) + let allEntries = $("#volunteerInformationCardToPrint .volunteerInfoEntries"); + let shownUsers = []; + allEntries.each(function () { + let currentEntry = $(this); + let user = currentEntry.data("user"); if (currentEntry.is(":visible")) { - if (shownUsers.includes(currentEntry.data("user"))) { + if (shownUsers.includes(user)) { currentEntry.hide() } else { - shownUsers.push(currentEntry.data("user")) + shownUsers.push(user); } } - } - stripeVolunteerInfoTable() + }); } function getCheckBoxes() { - $(".displayCheckbox").each(function () { - let checkboxId = this.id; - if ($('#' + checkboxId).is(':checked')) { - $("." + checkboxId).show() - } else { - $("." + checkboxId).hide() - } - }) - hideDuplicateVolunteers() + $(".displayCheckbox").each(function () { + let checkboxId = this.id; + let isChecked = $(this).is(':checked'); + if (checkboxId in columnMap) { + volunteerInfoTable.column(columnMap[checkboxId]).visible(isChecked); + } + if (isChecked) { + $("#volunteerInformationCardToPrint ." + checkboxId).show(); + } else { + $("#volunteerInformationCardToPrint ." + checkboxId).hide(); + } + }); + hideDuplicateVolunteers() + volunteerInfoTable.page('first').draw(false); + } - } function sortVolunteers() { - let sortedTable = $("#volunteerInformationTableToPrint_wrapper"); - let entriesTable = sortedTable.find(".volunteerInfoEntries"); - - entriesTable.sort(function (a, b) { - let textA = a.getElementsByClassName('nameSelect')[0].innerText - let textB = b.getElementsByClassName('nameSelect')[0].innerText - return textA.localeCompare(textB); - }); - - entriesTable.appendTo(sortedTable); - - let sortedCards = $("#volunteerInformationCardToPrint .sort-here"); - let entriesCards = sortedCards.find(".volunteerInfoEntries"); - - entriesCards.sort(function (a, b) { - let textA = a.getElementsByClassName('nameSelect')[0].innerText - let textB = b.getElementsByClassName('nameSelect')[0].innerText - return textA.localeCompare(textB); - }); - - entriesCards.appendTo(sortedCards); - }; - - function stripeVolunteerInfoTable() { - $('#volunteerInformationTableToPrint .volunteerInfoEntries').removeClass('custom-odd custom-even') - $('#volunteerInformationTableToPrint .volunteerInfoEntries:visible').each(function (i,e) { - $(e).addClass(i % 2 ? 'custom-odd' : 'custom-even') - }) - } + let sortedTable = $("#volunteerInformationTableToPrint_wrapper"); + let entriesTable = sortedTable.find(".volunteerInfoEntries"); + + entriesTable.sort(function (a, b) { + let textA = a.getElementsByClassName('nameSelect')[0].innerText + let textB = b.getElementsByClassName('nameSelect')[0].innerText + return textA.localeCompare(textB); + }); + let sortedCards = $("#volunteerInformationCardToPrint .sort-here"); + let entriesCards = sortedCards.find(".volunteerInfoEntries"); + entriesCards.appendTo(sortedCards); + }; + var volunteerInfoTable = $('#volunteerInformationTableToPrint').DataTable({pageLength: 10,order: [[0, 'asc']]}); getCheckBoxes() hideDuplicateVolunteers() sortVolunteers() - var volunteerInfoTable= $('#volunteerInformationTableToPrint').DataTable({ stripeClasses: []}); - volunteerInfoTable.on('draw.dt', function (){ - getCheckBoxes() - stripeVolunteerInfoTable() - }); - stripeVolunteerInfoTable() for(let i = 0; i < users.length; i++){ $('#volunteerUsernames').append(``) } diff --git a/app/templates/admin/bonnerManagement.html b/app/templates/admin/bonnerManagement.html index ebe02bf95..07f5a614e 100644 --- a/app/templates/admin/bonnerManagement.html +++ b/app/templates/admin/bonnerManagement.html @@ -82,7 +82,7 @@

{% set focus = "open" if visibleAccordion == "events" else "collapsed" %} - +

{% set show = "show" if visibleAccordion == "events" else "" %}
diff --git a/app/templates/admin/cceMinor.html b/app/templates/admin/cceMinor.html index 6e2042d6b..8705aea96 100644 --- a/app/templates/admin/cceMinor.html +++ b/app/templates/admin/cceMinor.html @@ -30,10 +30,11 @@

CCE Minor Progress

Sustained Engagements Summer Experience Requested Other Engagement + Remove from Minor - {% for student in sustainedEngagement %} + {% for student in cceMinorStudents %} {% from 'macros/graduationIconMacro.html' import graduationIcon %} @@ -57,6 +58,12 @@

CCE Minor Progress

No {% endif %} + + + {% endfor %} @@ -90,23 +97,17 @@

CCE Minor Candidates

'id': 'interested', 'label': 'Interested', 'students': interestedStudentsList, - 'emailString': interestedStudentEmailString, 'actionLabel': 'Declare Student', 'alwaysShow': true - }, - { - 'id': 'declared', - 'label': 'Declared', - 'students': declaredStudentsList, - 'emailString': declaredStudentEmailString, - 'actionLabel': 'Move To Interested', - 'alwaysShow': true } ] %} {% set activeTab = request.args.get('tab', 'interested') %} {{ minorCandidatesTab(tabsInfo, defaultActiveTab=activeTab )}}
+ + + diff --git a/app/templates/main/serviceTranscript.html b/app/templates/main/serviceTranscript.html index f30af37b4..5631f32b0 100644 --- a/app/templates/main/serviceTranscript.html +++ b/app/templates/main/serviceTranscript.html @@ -109,6 +109,33 @@
{{ programTitle }}
{% endif %} {% endfor %} + + {% if zeroHourEvents %} + {% set ns = namespace(filtered=[]) %} + {% for event in zeroHourEvents %} + {% if event.program.programName != "Bonner Scholars" %} + {% set _ = ns.filtered.append(event) %} + {% endif %} + {% endfor %} + + {% if ns.filtered %} +
+
Events with No Hours Earned
+ {% for event in ns.filtered %} +
+ + {{ event.term.description }} — {{ event.name }} - 0 hour + + {% if event.program.programName != "CELTS Sponsored Events" %} +

{{ event.program.description }}

+ More Information + {% endif %} +
+ {% endfor %} +
+ {% endif %} + {% endif %} + {% else %}
No Volunteer Record
diff --git a/app/templates/main/userProfile.html b/app/templates/main/userProfile.html index 6fee84932..13fafda69 100644 --- a/app/templates/main/userProfile.html +++ b/app/templates/main/userProfile.html @@ -97,10 +97,10 @@

- {% set focus = "open" if visibleAccordion == "upcoming" or (not visibleAccordion and volunteer.username == g.current_user.username) else "collapsed" %} + {% set focus = "open" if visibleAccordion == "upcoming" else "collapsed" %}

- {% set show = "show" if visibleAccordion == "upcoming" or (not visibleAccordion and volunteer.username == g.current_user.username) else ""%} + {% set show = "show" if visibleAccordion == "upcoming" else ""%}
@@ -167,12 +167,14 @@

Program Event Name + Participation Type Event Date {% for event in participatedEvents %} {{event.programName}} {{event.name}} + {{event.participatedType}} {{event.startDate.strftime('%m/%d/%Y')}} {% endfor %} @@ -189,12 +191,12 @@

You have not participated in any events.

- {% set focus = "open" if visibleAccordion == "programTable" or (not visibleAccordion and volunteer.username != g.current_user.username) else "collapsed" %} + {% set focus = "open" if visibleAccordion == "programTable" else "collapsed" %}

- {% set show = "show" if visibleAccordion == "programTable" or (not visibleAccordion and volunteer.username != g.current_user.username) else "" %} + {% set show = "show" if visibleAccordion == "programTable" else "" %}
@@ -206,7 +208,9 @@

Training {% if g.current_user.isCeltsAdmin or g.current_user.isCeltsStudentStaff%} Eligibility - On Transcript + {% if g.current_user.isCeltsAdmin%} + On Transcript + {% endif %} {% endif %} @@ -285,7 +289,7 @@

CELTS Labor History:
{% endif %} {% if volunteer.isCeltsStudentStaff %}
-
{{volunteer.firstName}} {{volunteer.lastName}} is the manager of:
+
{{volunteer.firstName}} {{volunteer.lastName}} is the current manager of:
    {% for program in programs %} {% if program.id in permissionPrograms %} @@ -340,14 +344,23 @@

    {{bgType.description}}
      - {% for bgStatus in allBackgroundHistory[bgType.id] %} -
    • - {{bgStatus.backgroundCheckStatus}}: {{bgStatus.dateCompleted.strftime("%m/%d/%Y")}} - {% if g.current_user.isCeltsAdmin %} - - {% endif %} -
    • - {% endfor %} + {% for bgStatus in allBackgroundHistory[bgType.id] %} +
    • + + {{ bgStatus.backgroundCheckStatus }}: + {{ bgStatus.dateCompleted.strftime("%m/%d/%Y") }} + + {% if g.current_user.isCeltsAdmin %} + + {% endif %} +
    • + {% endfor %}
    {% if g.current_user.isCeltsAdmin %} @@ -423,12 +436,13 @@

    {% else %} {{ "Bonner Scholar " if row.isBonnerNote else "Everyone"}} {% endif %} - - + {% if (g.current_user == row.note.createdBy) or g.current_user.isCeltsAdmin%} - + + {% else %} - + + {% endif %} @@ -484,7 +498,19 @@

    Notes
    {% if bonnerNotes|length %}
    {% for row in profileNotes|selectattr("isBonnerNote") %} -
    {{row.note.noteContent}} +
    {{row.note.noteContent}} + + +
    {{row.note.createdBy.fullName}} {{row.note.createdOn.strftime('%m/%d/%Y')}} {% endfor %}
    @@ -522,7 +548,7 @@
    Requirement Progress
+ + + + + +
-
End Date:  
+
End Date:  
*
diff --git a/app/templates/minor/cceMinorProposalManagement.html b/app/templates/minor/cceMinorProposalManagement.html index 275eaa6c4..e656b160b 100644 --- a/app/templates/minor/cceMinorProposalManagement.html +++ b/app/templates/minor/cceMinorProposalManagement.html @@ -7,30 +7,41 @@ Created By Supervisor Term - Status Action + {% for proposal in proposalList %} - {{ proposal['type'] }} - {{proposal['createdBy'].firstName}} {{proposal['createdBy'].lastName}} - {{proposal['supervisor']}} - {{ proposal['term'].description }} + {{ proposal.proposalType }} + {{proposal.createdBy.firstName}} {{proposal.createdBy.lastName}} + {{proposal.supervisorName}} + {{ proposal.term.description }} - {{proposal['status']}} - - - - - - - + {% if not proposal.isApproved or g.current_user.isCeltsAdmin %} + + {% endif %} + {% if g.current_user.isCeltsAdmin %} + {% if proposal.isApproved %} + + {% else %} + + {% endif %} + {% endif %} + + {% if g.current_user.isCeltsAdmin == True %} + + {% endif %} + + {% from 'macros/cceMinorStatusMacro.html' import minorStatusMacro %} + {{ minorStatusMacro(proposal.status) }} + {% endfor %} diff --git a/app/templates/minor/companyOrganizationInformation.html b/app/templates/minor/companyOrganizationInformation.html index f8c60ae75..087c009c3 100644 --- a/app/templates/minor/companyOrganizationInformation.html +++ b/app/templates/minor/companyOrganizationInformation.html @@ -6,14 +6,18 @@

Company/Organization Information

- +

- +

@@ -21,10 +25,14 @@

Company/Organization Information

- +
- +
\ No newline at end of file diff --git a/app/templates/minor/profile.html b/app/templates/minor/profile.html index 613a8055f..9cdd99c73 100644 --- a/app/templates/minor/profile.html +++ b/app/templates/minor/profile.html @@ -4,6 +4,7 @@ {% block scripts %} {{ super() }} + @@ -14,6 +15,7 @@ {% block styles %} {{ super() }} + {% endblock %} {% block app_content %} diff --git a/app/templates/minor/requestOtherEngagement.html b/app/templates/minor/requestOtherEngagement.html index d342bc18e..73a48fed8 100644 --- a/app/templates/minor/requestOtherEngagement.html +++ b/app/templates/minor/requestOtherEngagement.html @@ -17,7 +17,8 @@ {% endblock %} {% block app_content %} - + {% set viewing = (not editable) and proposal %} +
@@ -27,16 +28,32 @@

Proposal for Other Community Engaged E Please fill out this form to submit a proposal for another community engaged experience that pertains to at least one of the four following key content areas: power and inequality, community and identity, civic literacy, or civic skills.

- + + +

- + + {% if viewing %} + + {% endif %} {% for term in selectableTerms %} - + {% endfor %}
@@ -45,7 +62,9 @@

Proposal for Other Community Engaged E
- +
{% include "minor/companyOrganizationInformation.html" %} @@ -55,8 +74,9 @@

Proposal for Other Community Engaged E {% include "minor/supervisorInformation.html" %}
- - + +
@@ -67,27 +87,42 @@

Experience Information

- +

- +

- +


-
- +
+
+ +
+
+
+
+ + +
+
diff --git a/app/templates/minor/summerExperience.html b/app/templates/minor/summerExperience.html index 3f5384352..e58e4c24a 100644 --- a/app/templates/minor/summerExperience.html +++ b/app/templates/minor/summerExperience.html @@ -17,8 +17,9 @@ {% endblock %} {% block app_content %} + {% set viewing = not editable and proposal %}
-
+
@@ -27,14 +28,29 @@

Proposal for Community-Engaged Summer Experience

The minor requires an Intensive Community-Engaged Summer Experience that is focused on work that more deeply explores at least one of the four following key content areas: power and inequality, community and identity, civic literacy, or civic skills.

+
- - + + {% if viewing %} + + {% endif %} + {% for term in selectableTerms %} + {% endfor %}
@@ -55,61 +71,108 @@

Experience Information

-
+ +

- +
- +
- +
- +
- +
- +
-
+ {% if viewing and proposal.experienceDescription or not viewing %} + + + {% endif %} +

- +
- +
- +
- +


- +
- +

- + +

- + +


- -
- -
+
+
+ +
+
+
+
+ {% if not viewing %} + + + {% endif %} +
+
diff --git a/app/templates/minor/supervisorInformation.html b/app/templates/minor/supervisorInformation.html index 1df3e9a66..ae20a15d8 100644 --- a/app/templates/minor/supervisorInformation.html +++ b/app/templates/minor/supervisorInformation.html @@ -6,7 +6,9 @@

Supervisor Information

- +
@@ -15,11 +17,15 @@

Supervisor Information

- +
- +
\ No newline at end of file diff --git a/app/templates/serviceLearning/slcNewProposal.html b/app/templates/serviceLearning/slcNewProposal.html index 29ada7df8..374c27c7b 100644 --- a/app/templates/serviceLearning/slcNewProposal.html +++ b/app/templates/serviceLearning/slcNewProposal.html @@ -25,24 +25,28 @@
{% include "serviceLearning/slcQuestionnaire.html" %}
-
-
- - - - +
+
+
+
+
+
+
+
+
+
+
+ {% if g.current_user.isCeltsAdmin %} +
+ {% endif %} +
-
- - - -
-
- - - {% if g.current_user.isCeltsAdmin %} - - {% endif %} +
+
+ + + +
diff --git a/app/templates/sidebar.html b/app/templates/sidebar.html index f71ec1f6e..e1dd71e24 100644 --- a/app/templates/sidebar.html +++ b/app/templates/sidebar.html @@ -2,7 +2,7 @@ {% endblock %} -