Skip to content

Commit 55d707f

Browse files
committed
Add sort support, allow multi-prof views
1 parent 29e9c30 commit 55d707f

File tree

10 files changed

+370
-98
lines changed

10 files changed

+370
-98
lines changed

backend/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,25 @@ class ReviewFrontend(Review):
8080
votes_status: Vote
8181

8282

83+
class ReviewsMetadata(BaseModel):
84+
"""
85+
Base class for storing some metadata (aggregate statistics) of reviews
86+
"""
87+
88+
num_reviews: int
89+
newest_dtime: AwareDatetime | None
90+
avg_rating: float | None
91+
92+
# Model-level validator that runs before individual field validation
93+
@model_validator(mode="before")
94+
def convert_naive_to_aware(cls, values):
95+
if "newest_dtime" in values:
96+
dtime = values["newest_dtime"]
97+
if dtime and dtime.tzinfo is None:
98+
values["newest_dtime"] = dtime.replace(tzinfo=timezone.utc)
99+
return values
100+
101+
83102
class Member(BaseModel):
84103
"""
85104
Base class for representing a Member, can be a Student or Prof
@@ -102,6 +121,8 @@ class Prof(Member):
102121
Class for storing a Prof
103122
"""
104123

124+
reviews_metadata: ReviewsMetadata
125+
105126

106127
class Course(BaseModel):
107128
"""
@@ -113,6 +134,7 @@ class Course(BaseModel):
113134
sem: Sem
114135
name: str = Field(..., min_length=1)
115136
profs: list[EmailStr] # list of prof emails
137+
reviews_metadata: ReviewsMetadata
116138

117139

118140
class VoteAndReviewID(BaseModel):

backend/routes/courses.py

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from fastapi import APIRouter, Depends, HTTPException
33
from pydantic import EmailStr
44

5+
from routes.routes_helpers import get_list_with_metadata
56
from routes.members import prof_exists
67
from config import db
78
from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
@@ -21,32 +22,14 @@
2122

2223

2324
@router.get("/")
24-
async def course_list(
25-
course_sem_filter: Sem | None = None,
26-
course_code_filter: CourseCode | None = None,
27-
prof_filter: EmailStr | None = None,
28-
):
25+
async def course_list():
2926
"""
3027
List all courses.
3128
This does not return the reviews attribute, that must be queried individually.
32-
Can optionally pass filters for:
33-
- course semester
34-
- course code
35-
- prof
3629
"""
37-
filter_op: dict[str, Any] = {}
38-
if course_sem_filter:
39-
filter_op |= {"sem": course_sem_filter}
40-
if course_code_filter:
41-
filter_op |= {"code": course_code_filter}
42-
if prof_filter:
43-
filter_op |= {"profs": {"$all": [prof_filter]}}
44-
4530
return [
4631
Course(**course).model_dump()
47-
async for course in course_collection.find(
48-
filter_op, projection={"_id": False, "reviews": False}
49-
)
32+
async for course in get_list_with_metadata(course_collection)
5033
]
5134

5235

@@ -146,8 +129,7 @@ async def course_reviews_delete(
146129
If the user hasn't posted a review, no action will be taken.
147130
"""
148131
await course_collection.update_one(
149-
{"sem": sem, "code": code},
150-
{"$unset": {f"reviews.{auth_id}": ""}}
132+
{"sem": sem, "code": code}, {"$unset": {f"reviews.{auth_id}": ""}}
151133
)
152134

153135

backend/routes/members.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi import APIRouter, Depends, HTTPException
55
from pydantic import EmailStr
66

7+
from routes.routes_helpers import get_list_with_metadata
78
from config import db
89
from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
910
from models import Prof, Review, ReviewBackend, ReviewFrontend, Student, VoteAndReviewID
@@ -21,9 +22,7 @@ async def prof_list():
2122
"""
2223
return [
2324
Prof(**user).model_dump()
24-
async for user in profs_collection.find(
25-
projection={"_id": False, "reviews": False}
26-
)
25+
async for user in get_list_with_metadata(profs_collection)
2726
]
2827

2928

backend/routes/routes_helpers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from motor.motor_asyncio import AsyncIOMotorCollection
2+
3+
REVIEWS_TO_LIST_STEP = {"$objectToArray": {"$ifNull": ["$reviews", {}]}}
4+
METADATA_PIPELINE_PROJECT = {
5+
"_id": 0,
6+
"email": 1,
7+
"code": 1,
8+
"sem": 1,
9+
"profs": 1,
10+
"name": 1,
11+
"reviews_metadata": {
12+
"num_reviews": {"$size": REVIEWS_TO_LIST_STEP},
13+
"newest_dtime": {
14+
"$max": {
15+
"$map": {
16+
"input": REVIEWS_TO_LIST_STEP,
17+
"as": "entry",
18+
"in": "$$entry.v.dtime",
19+
},
20+
},
21+
},
22+
"avg_rating": {
23+
"$avg": {
24+
"$map": {
25+
"input": REVIEWS_TO_LIST_STEP,
26+
"as": "entry",
27+
"in": "$$entry.v.rating",
28+
}
29+
}
30+
},
31+
},
32+
}
33+
34+
35+
def get_list_with_metadata(collection: AsyncIOMotorCollection):
36+
return collection.aggregate([{"$project": METADATA_PIPELINE_PROJECT}])

frontend/src/App.tsx

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,6 @@ const App: React.FC = () => {
5858
);
5959

6060
const response_courses = await api.get<CourseType[]>('/courses/');
61-
response_courses.data.sort((a, b) => {
62-
// Extract year and term (S/M) for comparison
63-
const [termA, yearA] = [a.sem[0], parseInt(a.sem.slice(1))];
64-
const [termB, yearB] = [b.sem[0], parseInt(b.sem.slice(1))];
65-
66-
// Compare by year first (descending order)
67-
if (yearA !== yearB) {
68-
return yearB - yearA;
69-
}
70-
71-
// If the year is the same, compare by term (M before S)
72-
if (termA !== termB) {
73-
return termA === 'M' ? -1 : 1;
74-
}
75-
76-
// If the semester is the same, compare by name (ascending order)
77-
return a.name.localeCompare(b.name);
78-
});
7961
setCourseList(response_courses.data);
8062
} else {
8163
logoutHandler();
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { ReviewableType, SortType } from '../types';
3+
import {
4+
Typography,
5+
ToggleButtonGroup,
6+
ToggleButton,
7+
Stack,
8+
} from '@mui/material';
9+
import { reviewableDefaultSortString, reviewableSort } from '../sortutils';
10+
11+
type SortBoxProps<T extends ReviewableType> = {
12+
sortableData: T[];
13+
setSortableData: (value: T[]) => void;
14+
};
15+
16+
const SortBox = <T extends ReviewableType>({
17+
sortableData,
18+
setSortableData,
19+
}: SortBoxProps<T>): React.ReactElement => {
20+
const [sortBy, setSortBy] = useState<SortType | ''>('');
21+
const [sortByAscending, setSortByAscending] = useState<boolean>(false);
22+
23+
const handleSortChange = (
24+
event: React.MouseEvent<HTMLElement>,
25+
newValue: SortType | null
26+
) => {
27+
if (newValue !== null && newValue !== sortBy) {
28+
setSortBy(newValue);
29+
}
30+
};
31+
const handleSortAscendingChange = (
32+
event: React.MouseEvent<HTMLElement>,
33+
newValue: boolean | null
34+
) => {
35+
if (newValue !== null && newValue !== sortByAscending) {
36+
setSortByAscending(newValue);
37+
}
38+
};
39+
40+
useEffect(() => {
41+
setSortableData(reviewableSort(sortableData, sortBy, sortByAscending));
42+
}, [sortBy, sortByAscending]);
43+
44+
// Reset sort criteria to defaults if data changes in parent
45+
useEffect(() => {
46+
setSortBy('');
47+
setSortByAscending(false);
48+
}, [reviewableDefaultSortString(sortableData)]);
49+
50+
const disableForSize = sortableData === null || sortableData.length <= 1;
51+
return (
52+
<>
53+
<Typography variant="h5" color="secondary" gutterBottom>
54+
Sort By
55+
</Typography>
56+
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
57+
<ToggleButtonGroup
58+
color="primary"
59+
value={sortBy}
60+
exclusive
61+
onChange={handleSortChange}
62+
size="small"
63+
disabled={disableForSize}
64+
>
65+
<ToggleButton value="">None</ToggleButton>
66+
<ToggleButton value="num_reviews">No. of reviews</ToggleButton>
67+
<ToggleButton value="avg_rating">Average rating</ToggleButton>
68+
<ToggleButton value="newest_dtime">Most recent comment</ToggleButton>
69+
</ToggleButtonGroup>
70+
<ToggleButtonGroup
71+
color="primary"
72+
value={sortBy ? sortByAscending : null}
73+
exclusive
74+
onChange={handleSortAscendingChange}
75+
size="small"
76+
disabled={disableForSize || !sortBy}
77+
>
78+
<ToggleButton value={true}>Ascending</ToggleButton>
79+
<ToggleButton value={false}>Descending</ToggleButton>
80+
</ToggleButtonGroup>
81+
</Stack>
82+
<Typography
83+
variant="body2"
84+
color="text.primary"
85+
sx={{ mt: 1, mb: 3, fontStyle: 'italic' }}
86+
>
87+
You can pick parameters to sort the boxes displayed.
88+
{sortBy && ' All the boxes with no reviews will be at the bottom.'}
89+
</Typography>
90+
</>
91+
);
92+
};
93+
94+
export default SortBox;

0 commit comments

Comments
 (0)