Skip to content

Commit

Permalink
Add sort support, allow multi-prof views
Browse files Browse the repository at this point in the history
  • Loading branch information
ankith26 committed Dec 29, 2024
1 parent 29e9c30 commit a4c13e6
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 65 deletions.
22 changes: 22 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -102,6 +121,8 @@ class Prof(Member):
Class for storing a Prof
"""

reviews_metadata: ReviewsMetadata


class Course(BaseModel):
"""
Expand All @@ -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):
Expand Down
26 changes: 4 additions & 22 deletions backend/routes/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
]


Expand Down Expand Up @@ -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}": ""}}
)


Expand Down
5 changes: 2 additions & 3 deletions backend/routes/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
]


Expand Down
36 changes: 36 additions & 0 deletions backend/routes/routes_helpers.py
Original file line number Diff line number Diff line change
@@ -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}])
18 changes: 0 additions & 18 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,6 @@ const App: React.FC = () => {
);

const response_courses = await api.get<CourseType[]>('/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();
Expand Down
123 changes: 123 additions & 0 deletions frontend/src/components/SortBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react';
import { ReviewableType, SortType } from '../types';
import {
Typography,
ToggleButtonGroup,
ToggleButton,
Stack,
} from '@mui/material';
import { reviewableCompare } from '../utils';

type SortBoxProps<T extends ReviewableType> = {
sortableData: T[] | null;
setSortableData: (value: T[]) => void;
};

const SortBox = <T extends ReviewableType>({
sortableData,
setSortableData,
}: SortBoxProps<T>): React.ReactElement => {
const [sortBy, setSortBy] = useState<SortType | ''>('');
const [sortByAscending, setSortByAscending] = useState<boolean>(true);

const handleSortChange = (
event: React.MouseEvent<HTMLElement>,
newValue: SortType | null
) => {
if (newValue !== null && newValue !== sortBy) {
setSortBy(newValue);
}
};
const handleSortAscendingChange = (
event: React.MouseEvent<HTMLElement>,
newValue: boolean | null
) => {
if (newValue !== null && newValue !== sortByAscending) {
setSortByAscending(newValue);
}
};

useEffect(() => {
if (!sortableData) {
return;
}

setSortableData(
[...sortableData].sort((a, b) => {
if (!sortBy) {
return reviewableCompare(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;
}
})
);
}, [sortBy, sortByAscending]);

const disableForSize = sortableData === null || sortableData.length <= 1;
return (
<>
<Typography variant="h5" color="secondary" gutterBottom>
Sort By
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<ToggleButtonGroup
color="primary"
value={sortBy}
exclusive
onChange={handleSortChange}
size="small"
disabled={disableForSize}
>
<ToggleButton value="">None</ToggleButton>
<ToggleButton value="num_reviews">No. of reviews</ToggleButton>
<ToggleButton value="avg_rating">Average rating</ToggleButton>
<ToggleButton value="newest_dtime">Most recent comment</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
color="primary"
value={sortBy ? sortByAscending : null}
exclusive
onChange={handleSortAscendingChange}
size="small"
disabled={disableForSize || !sortBy}
>
<ToggleButton value={true}>Ascending</ToggleButton>
<ToggleButton value={false}>Descending</ToggleButton>
</ToggleButtonGroup>
</Stack>
<Typography
variant="body2"
color="text.primary"
sx={{ mt: 1, mb: 3, fontStyle: 'italic' }}
>
You can pick parameters to sort the boxes displayed.
{sortBy && ' All the boxes with no reviews will be at the bottom.'}
</Typography>
</>
);
};

export default SortBox;
39 changes: 36 additions & 3 deletions frontend/src/pages/Courses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../utils';

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;
Expand Down Expand Up @@ -58,7 +72,7 @@ const Courses: React.FC<{
)
.map((course) => course.sem)
)
);
).sort(semCompare);

const seen = new Set();
const codeOptions = courseList
Expand Down Expand Up @@ -90,11 +104,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 (
<Container sx={{ mt: 3, mb: 3, color: 'text.primary' }}>
<Typography variant="h4" color="primary" gutterBottom align="center">
Course Reviews
</Typography>
<Typography variant="h5" color="secondary" gutterBottom>
Filters
</Typography>
<Box
display="flex"
alignItems="center"
Expand Down Expand Up @@ -160,6 +180,20 @@ const Courses: React.FC<{
</span>
</Tooltip>
</Box>
<Typography
variant="body2"
color="text.primary"
sx={{ mb: 3, fontStyle: 'italic' }}
>
All filters are optional but you have to set atleast one of the three.
</Typography>
<SortBox
sortableData={filteredCourses}
setSortableData={setFilteredCourses}
/>
<Typography variant="h5" color="secondary" gutterBottom>
Reviews
</Typography>
{filteredCourses ? (
filteredCourses.length <= 0 ? (
<Typography
Expand Down Expand Up @@ -194,7 +228,6 @@ const Courses: React.FC<{
color="text.primary"
sx={{ fontStyle: 'italic' }}
>
All filters are optional but you have to set atleast one of the three.
Hit search after choosing your filter(s).
</Typography>
)}
Expand Down
Loading

0 comments on commit a4c13e6

Please sign in to comment.