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 55d707f
Show file tree
Hide file tree
Showing 10 changed files with 370 additions and 98 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
94 changes: 94 additions & 0 deletions frontend/src/components/SortBox.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends ReviewableType> = {
sortableData: T[];
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>(false);

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(() => {
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 (
<>
<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;
Loading

0 comments on commit 55d707f

Please sign in to comment.