diff --git a/backend/models.py b/backend/models.py index df982d4..501a3ad 100644 --- a/backend/models.py +++ b/backend/models.py @@ -80,6 +80,25 @@ class ReviewFrontend(Review): votes_status: Vote +class ReviewsMetadata(BaseModel): + """ + Base class for storing some metadata (aggregate statistics) of reviews + """ + + num_reviews: int + newest_dtime: AwareDatetime | None + avg_rating: float | None + + # Model-level validator that runs before individual field validation + @model_validator(mode="before") + def convert_naive_to_aware(cls, values): + if "newest_dtime" in values: + dtime = values["newest_dtime"] + if dtime and dtime.tzinfo is None: + values["newest_dtime"] = dtime.replace(tzinfo=timezone.utc) + return values + + class Member(BaseModel): """ Base class for representing a Member, can be a Student or Prof @@ -102,6 +121,8 @@ class Prof(Member): Class for storing a Prof """ + reviews_metadata: ReviewsMetadata + class Course(BaseModel): """ @@ -113,6 +134,7 @@ class Course(BaseModel): sem: Sem name: str = Field(..., min_length=1) profs: list[EmailStr] # list of prof emails + reviews_metadata: ReviewsMetadata class VoteAndReviewID(BaseModel): diff --git a/backend/routes/courses.py b/backend/routes/courses.py index ff5103b..79bbe75 100644 --- a/backend/routes/courses.py +++ b/backend/routes/courses.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import EmailStr +from routes.routes_helpers import get_list_with_metadata from routes.members import prof_exists from config import db from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt @@ -21,32 +22,14 @@ @router.get("/") -async def course_list( - course_sem_filter: Sem | None = None, - course_code_filter: CourseCode | None = None, - prof_filter: EmailStr | None = None, -): +async def course_list(): """ List all courses. This does not return the reviews attribute, that must be queried individually. - Can optionally pass filters for: - - course semester - - course code - - prof """ - filter_op: dict[str, Any] = {} - if course_sem_filter: - filter_op |= {"sem": course_sem_filter} - if course_code_filter: - filter_op |= {"code": course_code_filter} - if prof_filter: - filter_op |= {"profs": {"$all": [prof_filter]}} - return [ Course(**course).model_dump() - async for course in course_collection.find( - filter_op, projection={"_id": False, "reviews": False} - ) + async for course in get_list_with_metadata(course_collection) ] @@ -146,8 +129,7 @@ async def course_reviews_delete( If the user hasn't posted a review, no action will be taken. """ await course_collection.update_one( - {"sem": sem, "code": code}, - {"$unset": {f"reviews.{auth_id}": ""}} + {"sem": sem, "code": code}, {"$unset": {f"reviews.{auth_id}": ""}} ) diff --git a/backend/routes/members.py b/backend/routes/members.py index 1debe8f..062fe40 100644 --- a/backend/routes/members.py +++ b/backend/routes/members.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import EmailStr +from routes.routes_helpers import get_list_with_metadata from config import db from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt from models import Prof, Review, ReviewBackend, ReviewFrontend, Student, VoteAndReviewID @@ -21,9 +22,7 @@ async def prof_list(): """ return [ Prof(**user).model_dump() - async for user in profs_collection.find( - projection={"_id": False, "reviews": False} - ) + async for user in get_list_with_metadata(profs_collection) ] diff --git a/backend/routes/routes_helpers.py b/backend/routes/routes_helpers.py new file mode 100644 index 0000000..4f31036 --- /dev/null +++ b/backend/routes/routes_helpers.py @@ -0,0 +1,36 @@ +from motor.motor_asyncio import AsyncIOMotorCollection + +REVIEWS_TO_LIST_STEP = {"$objectToArray": {"$ifNull": ["$reviews", {}]}} +METADATA_PIPELINE_PROJECT = { + "_id": 0, + "email": 1, + "code": 1, + "sem": 1, + "profs": 1, + "name": 1, + "reviews_metadata": { + "num_reviews": {"$size": REVIEWS_TO_LIST_STEP}, + "newest_dtime": { + "$max": { + "$map": { + "input": REVIEWS_TO_LIST_STEP, + "as": "entry", + "in": "$$entry.v.dtime", + }, + }, + }, + "avg_rating": { + "$avg": { + "$map": { + "input": REVIEWS_TO_LIST_STEP, + "as": "entry", + "in": "$$entry.v.rating", + } + } + }, + }, +} + + +def get_list_with_metadata(collection: AsyncIOMotorCollection): + return collection.aggregate([{"$project": METADATA_PIPELINE_PROJECT}]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e22cbbf..db00e73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,24 +58,6 @@ const App: React.FC = () => { ); const response_courses = await api.get('/courses/'); - response_courses.data.sort((a, b) => { - // Extract year and term (S/M) for comparison - const [termA, yearA] = [a.sem[0], parseInt(a.sem.slice(1))]; - const [termB, yearB] = [b.sem[0], parseInt(b.sem.slice(1))]; - - // Compare by year first (descending order) - if (yearA !== yearB) { - return yearB - yearA; - } - - // If the year is the same, compare by term (M before S) - if (termA !== termB) { - return termA === 'M' ? -1 : 1; - } - - // If the semester is the same, compare by name (ascending order) - return a.name.localeCompare(b.name); - }); setCourseList(response_courses.data); } else { logoutHandler(); diff --git a/frontend/src/components/SortBox.tsx b/frontend/src/components/SortBox.tsx new file mode 100644 index 0000000..39b8f3d --- /dev/null +++ b/frontend/src/components/SortBox.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import { ReviewableType, SortType } from '../types'; +import { + Typography, + ToggleButtonGroup, + ToggleButton, + Stack, +} from '@mui/material'; +import { reviewableDefaultSortString, reviewableSort } from '../sortutils'; + +type SortBoxProps = { + sortableData: T[]; + setSortableData: (value: T[]) => void; +}; + +const SortBox = ({ + sortableData, + setSortableData, +}: SortBoxProps): React.ReactElement => { + const [sortBy, setSortBy] = useState(''); + const [sortByAscending, setSortByAscending] = useState(false); + + const handleSortChange = ( + event: React.MouseEvent, + newValue: SortType | null + ) => { + if (newValue !== null && newValue !== sortBy) { + setSortBy(newValue); + } + }; + const handleSortAscendingChange = ( + event: React.MouseEvent, + newValue: boolean | null + ) => { + if (newValue !== null && newValue !== sortByAscending) { + setSortByAscending(newValue); + } + }; + + useEffect(() => { + setSortableData(reviewableSort(sortableData, sortBy, sortByAscending)); + }, [sortBy, sortByAscending]); + + // Reset sort criteria to defaults if data changes in parent + useEffect(() => { + setSortBy(''); + setSortByAscending(false); + }, [reviewableDefaultSortString(sortableData)]); + + const disableForSize = sortableData === null || sortableData.length <= 1; + return ( + <> + + Sort By + + + + None + No. of reviews + Average rating + Most recent comment + + + Ascending + Descending + + + + You can pick parameters to sort the boxes displayed. + {sortBy && ' All the boxes with no reviews will be at the bottom.'} + + + ); +}; + +export default SortBox; diff --git a/frontend/src/pages/Courses.tsx b/frontend/src/pages/Courses.tsx index c896184..e021c82 100644 --- a/frontend/src/pages/Courses.tsx +++ b/frontend/src/pages/Courses.tsx @@ -13,6 +13,20 @@ import FullPageLoader from '../components/FullPageLoader'; import ReviewBox from '../components/ReviewBox'; import { CourseType, NameAndCode, ProfType } from '../types'; +import SortBox from '../components/SortBox'; +import { semCompare, reviewableSort } from '../sortutils'; + +function getProfStub(email: string): ProfType { + return { + name: email, + email: email, + reviews_metadata: { + num_reviews: 0, + newest_dtime: null, + avg_rating: null, + }, + }; +} const Courses: React.FC<{ courseList: CourseType[] | undefined; @@ -21,14 +35,12 @@ const Courses: React.FC<{ const [semFilter, setSemFilter] = useState(null); const [codeFilter, setCodeFilter] = useState(null); const [profFilter, setProfFilter] = useState(null); - const [filteredCourses, setFilteredCourses] = useState( - null - ); + const [filteredCourses, setFilteredCourses] = useState([]); const applyFilters = () => { if (!semFilter && !codeFilter && !profFilter) { /* No filters chosen */ - setFilteredCourses(null); + setFilteredCourses([]); return; } if (courseList) { @@ -40,7 +52,7 @@ const Courses: React.FC<{ : true; return matchesSem && matchesCode && matchesProf; }); - setFilteredCourses(filtered); + setFilteredCourses(reviewableSort(filtered)); } }; @@ -58,7 +70,7 @@ const Courses: React.FC<{ ) .map((course) => course.sem) ) - ); + ).sort(semCompare); const seen = new Set(); const codeOptions = courseList @@ -90,11 +102,17 @@ const Courses: React.FC<{ .flatMap((course) => course.profs) ) ) - .map((email) => profMap.get(email) || { name: email, email: email }) + .map((email) => profMap.get(email) || getProfStub(email)) .sort((a, b) => a.name.localeCompare(b.name)); return ( + + Course Reviews + + + Filters + - {filteredCourses ? ( - filteredCourses.length <= 0 ? ( - + All filters are optional but you have to set atleast one of the three. + + + + Reviews + + {filteredCourses.length > 0 ? ( + filteredCourses.map((course, index) => ( + - No match found for given filters. - - ) : ( - filteredCourses.map((course, index) => ( - - {course.profs.map((email) => { - const prof = profMap.get(email); - return prof ? ( - - {prof.name} <{email}> - - ) : null; - })} - - )) - ) + {course.profs.map((email) => { + const prof = profMap.get(email); + return prof ? ( + + {prof.name} <{email}> + + ) : null; + })} + + )) ) : ( - All filters are optional but you have to set atleast one of the three. - Hit search after choosing your filter(s). + No matches found, make sure to hit the search button after picking the + filter(s). )} diff --git a/frontend/src/pages/Profs.tsx b/frontend/src/pages/Profs.tsx index 4c9dec7..eeafdf2 100644 --- a/frontend/src/pages/Profs.tsx +++ b/frontend/src/pages/Profs.tsx @@ -13,72 +13,108 @@ import FullPageLoader from '../components/FullPageLoader'; import ReviewBox from '../components/ReviewBox'; import { ProfType } from '../types'; +import SortBox from '../components/SortBox'; +import { reviewableEqual, reviewableSort } from '../sortutils'; const Profs: React.FC<{ profList: ProfType[] | undefined }> = ({ profList, }) => { - const [selectedProf, setSelectedProf] = useState(null); - const [reviewProf, setReviewProf] = useState(null); + const [selectedProfs, setSelectedProfs] = useState([]); + const [displayProfs, setDisplayProfs] = useState([]); if (profList === undefined) { return ; } + if (displayProfs.length === 0 && profList.length !== 0) { + setSelectedProfs(profList); + setDisplayProfs(profList); + } + return ( + + Professor Reviews + + + Filter + `${option.name} <${option.email}>`} - onChange={(event, newValue) => setSelectedProf(newValue)} + onChange={(event, newValue) => + setSelectedProfs(newValue.length ? newValue : profList) + } renderInput={(params) => ( )} - sx={{ borderRadius: 2, flexGrow: 1 }} + sx={{ borderRadius: 2, width: '100%' }} size="small" /> - {reviewProf ? ( - - {reviewProf.email} - + + This filter allows you to select one or more professors to display + reviews for. To clear this filter and display all, leave this empty. + + + + Reviews + + {displayProfs.length > 0 ? ( + displayProfs.map((prof, index) => ( + + {prof.email} + + )) ) : ( - Please pick a professor from the dropdown and then hit search. + No reviews available. )} diff --git a/frontend/src/sortutils.tsx b/frontend/src/sortutils.tsx new file mode 100644 index 0000000..e2ae726 --- /dev/null +++ b/frontend/src/sortutils.tsx @@ -0,0 +1,85 @@ +import { ReviewableType, SortType } from './types'; + +const semCompare = (a: string, b: string) => { + // Extract year and term (S/M) for comparison + const [termA, yearA] = [a[0], parseInt(a.slice(1))]; + const [termB, yearB] = [b[0], parseInt(b.slice(1))]; + + // Compare by year first (descending order) + if (yearA !== yearB) { + return yearB - yearA; + } + + // If the year is the same, compare by term (M before S) + if (termA !== termB) { + return termA === 'M' ? -1 : 1; + } + + return 0; +}; + +const reviewableDefaultCompare = (a: T, b: T) => { + if ('sem' in a && 'sem' in b) { + // First compare by semester, if that is same compare by name (ascending order) + const res = semCompare(a.sem, b.sem); + if (res) { + return res; + } + } + return a.name.localeCompare(b.name); +}; + +const reviewableDefaultSortString = ( + arr: T[] | null +) => { + return JSON.stringify(arr && [...arr].sort(reviewableDefaultCompare)); +}; + +const reviewableEqual = (a: T[], b: T[]) => { + if (a.length !== b.length) return false; + return reviewableDefaultSortString(a) === reviewableDefaultSortString(b); +}; + +const reviewableSort = ( + sortableData: T[], + sortBy: SortType | '' = '', + sortByAscending: boolean = false +) => { + return [...sortableData].sort((a, b) => { + if (!sortBy) { + return reviewableDefaultCompare(a, b); + } + + const left = a.reviews_metadata[sortBy]; + const right = b.reviews_metadata[sortBy]; + if (right === left) { + /* Both null or both equal, return 0 */ + return 0; + } + + /* always settle the null entries at the end */ + if (right === null || right === 0) { + return -1; + } + if (left === null || left === 0) { + return 1; + } + + if (typeof left === 'number' && typeof right === 'number') { + return sortByAscending ? left - right : right - left; + } else if (typeof left === 'string' && typeof right === 'string') { + return sortByAscending + ? left.localeCompare(right) + : right.localeCompare(left); + } else { + return 0; + } + }); +}; + +export { + semCompare, + reviewableDefaultSortString, + reviewableEqual, + reviewableSort, +}; diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index c91e92d..e2c16d6 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -14,9 +14,16 @@ interface ReviewType { votes_status: Vote; } +interface ReviewsMetadata { + num_reviews: number; + newest_dtime: string | null; + avg_rating: number | null; +} + interface ProfType { name: string; email: string; + reviews_metadata: ReviewsMetadata; } interface CourseType { @@ -24,12 +31,17 @@ interface CourseType { code: string; sem: string; profs: string[]; + reviews_metadata: ReviewsMetadata; } interface NameAndCode { name: string; code: string; } + +type SortType = keyof ReviewsMetadata; +type ReviewableType = CourseType | ProfType; + export type { ErrorMessageCallback, LogoutCallback, @@ -39,4 +51,6 @@ export type { ProfType, CourseType, NameAndCode, + SortType, + ReviewableType, };