diff --git a/backend/models.py b/backend/models.py
index e642ab1..a508da1 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -26,6 +26,9 @@
# TODO: can make this regex more precise
StudentRollno: TypeAlias = Annotated[str, StringConstraints(pattern=r"^\d{10}$")]
+# A vote can be 1 (upvote), -1 (downvote) or 0 (no vote)
+Vote: TypeAlias = Literal[-1, 0, 1]
+
class Review(BaseModel):
"""
@@ -36,10 +39,6 @@ class Review(BaseModel):
content: str = Field(..., min_length=1, max_length=MSG_MAX_LEN)
dtime: AwareDatetime = Field(default_factory=lambda: datetime.now(timezone.utc))
- # TODO: upvote/downvote system
- # upvoters: set[str] # set of student emails
- # downvoters: set[str] # set of student emails
-
# Model-level validator that runs before individual field validation
@model_validator(mode="before")
def convert_naive_to_aware(cls, values):
@@ -50,14 +49,36 @@ def convert_naive_to_aware(cls, values):
return values
+class ReviewBackend(Review):
+ """
+ This represents a Review as it is stored in the backend (db).
+ """
+
+ # mapping from student hash to vote.
+ # this dict is not to be exposed to the frontend directly, as the hashes
+ # must not be exposed.
+ votes: dict[str, Vote] = Field(default_factory=dict)
+
+
class ReviewFrontend(Review):
"""
This represents a Review as it is seen from the frontend. Some attributes
with the backend are common, but some are not.
"""
+ # The id of the Review as visible to the frontend. This is the encrypted
+ # reviewer hash.
+ review_id: str
+
+ # stores whether viewer is the author of the review
is_reviewer: bool
+ # aggregate of votes
+ votes_aggregate: int
+
+ # stores the upvote/downvote status of the author
+ votes_status: Vote
+
class Member(BaseModel):
"""
@@ -92,3 +113,12 @@ class Course(BaseModel):
sem: Sem
name: str = Field(..., min_length=1)
profs: list[EmailStr] # list of prof emails
+
+
+class VoteAndReviewID(BaseModel):
+ """
+ Base class for storing a vote and review_id (used in post body for vote API)
+ """
+
+ vote: Vote
+ review_id: str
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 896ca98..1a29086 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,24 +1,36 @@
+# To regen this file: run the following in a fresh venv
+# pip install fastapi email-validator uvicorn[standard] pyjwt cryptography motor python-cas
+# pip freeze
annotated-types==0.7.0
-anyio==4.6.2.post1
-certifi==2024.8.30
+anyio==4.7.0
+certifi==2024.12.14
+cffi==1.17.1
charset-normalizer==3.4.0
click==8.1.7
+cryptography==44.0.0
dnspython==2.7.0
email_validator==2.2.0
-fastapi==0.115.5
+fastapi==0.115.6
h11==0.14.0
+httptools==0.6.4
idna==3.10
lxml==5.3.0
motor==3.6.0
+pycparser==2.22
pydantic==2.10.3
pydantic_core==2.27.1
PyJWT==2.10.1
pymongo==4.9.2
python-cas==1.6.0
+python-dotenv==1.0.1
+PyYAML==6.0.2
requests==2.32.3
-six==1.16.0
+six==1.17.0
sniffio==1.3.1
starlette==0.41.3
typing_extensions==4.12.2
urllib3==2.2.3
-uvicorn==0.32.1
+uvicorn==0.34.0
+uvloop==0.21.0
+watchfiles==1.0.3
+websockets==14.1
diff --git a/backend/routes/courses.py b/backend/routes/courses.py
index 7b59c16..ff5103b 100644
--- a/backend/routes/courses.py
+++ b/backend/routes/courses.py
@@ -4,8 +4,16 @@
from routes.members import prof_exists
from config import db
-from utils import get_auth_id, get_auth_id_admin
-from models import Course, Review, ReviewFrontend, Sem, CourseCode
+from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
+from models import (
+ Course,
+ Review,
+ ReviewBackend,
+ ReviewFrontend,
+ Sem,
+ CourseCode,
+ VoteAndReviewID,
+)
# The get_auth_id Dependency validates authentication of the caller
router = APIRouter(dependencies=[Depends(get_auth_id)])
@@ -87,9 +95,21 @@ async def course_reviews_get(
if not course_reviews:
return None
+ course_reviews_validated = [
+ (k, ReviewBackend(**v)) for k, v in course_reviews.get("reviews", {}).items()
+ ]
+
return [
- ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump()
- for k, v in course_reviews.get("reviews", {}).items()
+ ReviewFrontend(
+ rating=v.rating,
+ content=v.content,
+ dtime=v.dtime,
+ review_id=hash_encrypt(k),
+ is_reviewer=(k == auth_id),
+ votes_aggregate=sum(v.votes.values()),
+ votes_status=v.votes.get(auth_id, 0),
+ ).model_dump()
+ for k, v in course_reviews_validated
]
@@ -104,7 +124,16 @@ async def course_reviews_post(
"""
await course_collection.update_one(
{"sem": sem, "code": code},
- {"$set": {f"reviews.{auth_id}": review.model_dump()}},
+ [
+ {
+ "$set": {
+ # do merge objects to keep old votes intact
+ f"reviews.{auth_id}": {
+ "$mergeObjects": [f"$reviews.{auth_id}", review.model_dump()]
+ }
+ }
+ }
+ ],
)
@@ -120,3 +149,27 @@ async def course_reviews_delete(
{"sem": sem, "code": code},
{"$unset": {f"reviews.{auth_id}": ""}}
)
+
+
+@router.post("/reviews/{sem}/{code}/votes")
+async def course_reviews_votes_post(
+ sem: Sem,
+ code: CourseCode,
+ post_body: VoteAndReviewID,
+ auth_id: str = Depends(get_auth_id),
+):
+ """
+ Helper to post a vote on a single Review on a Course.
+ """
+ review_hash = hash_decrypt(post_body.review_id)
+ if not review_hash:
+ raise HTTPException(422, "Invalid review_id value")
+
+ await course_collection.update_one(
+ {"sem": sem, "code": code},
+ {
+ "$set" if post_body.vote else "$unset": {
+ f"reviews.{review_hash}.votes.{auth_id}": post_body.vote
+ }
+ },
+ )
diff --git a/backend/routes/members.py b/backend/routes/members.py
index c282f22..1debe8f 100644
--- a/backend/routes/members.py
+++ b/backend/routes/members.py
@@ -5,8 +5,8 @@
from pydantic import EmailStr
from config import db
-from utils import get_auth_id, get_auth_id_admin
-from models import Prof, Review, ReviewFrontend, Student
+from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
+from models import Prof, Review, ReviewBackend, ReviewFrontend, Student, VoteAndReviewID
# The get_auth_id Dependency validates authentication of the caller
router = APIRouter(dependencies=[Depends(get_auth_id)])
@@ -65,9 +65,21 @@ async def prof_reviews_get(email: EmailStr, auth_id: str = Depends(get_auth_id))
if not prof_reviews:
return None
+ prof_reviews_validated = [
+ (k, ReviewBackend(**v)) for k, v in prof_reviews.get("reviews", {}).items()
+ ]
+
return [
- ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump()
- for k, v in prof_reviews.get("reviews", {}).items()
+ ReviewFrontend(
+ rating=v.rating,
+ content=v.content,
+ dtime=v.dtime,
+ review_id=hash_encrypt(k),
+ is_reviewer=(k == auth_id),
+ votes_aggregate=sum(v.votes.values()),
+ votes_status=v.votes.get(auth_id, 0),
+ ).model_dump()
+ for k, v in prof_reviews_validated
]
@@ -81,7 +93,17 @@ async def prof_reviews_post(
review discards any older reviews.
"""
await profs_collection.update_one(
- {"email": email}, {"$set": {f"reviews.{auth_id}": review.model_dump()}}
+ {"email": email},
+ [
+ {
+ "$set": {
+ # do merge objects to keep old votes intact
+ f"reviews.{auth_id}": {
+ "$mergeObjects": [f"$reviews.{auth_id}", review.model_dump()]
+ }
+ }
+ }
+ ],
)
@@ -96,6 +118,29 @@ async def prof_reviews_delete(email: EmailStr, auth_id: str = Depends(get_auth_i
)
+@router.post("/reviews/{email}/votes")
+async def course_reviews_votes_post(
+ email: EmailStr,
+ post_body: VoteAndReviewID,
+ auth_id: str = Depends(get_auth_id),
+):
+ """
+ Helper to post a vote on a single Review on a Prof.
+ """
+ review_hash = hash_decrypt(post_body.review_id)
+ if not review_hash:
+ raise HTTPException(422, "Invalid review_id value")
+
+ await profs_collection.update_one(
+ {"email": email},
+ {
+ "$set" if post_body.vote else "$unset": {
+ f"reviews.{review_hash}.votes.{auth_id}": post_body.vote
+ }
+ },
+ )
+
+
async def student_hash(user: Student):
"""
Internal function to hash a Student object. This hash is used as a review key
diff --git a/backend/utils.py b/backend/utils.py
index 2cb796c..d29f38a 100644
--- a/backend/utils.py
+++ b/backend/utils.py
@@ -1,3 +1,5 @@
+import base64
+from cryptography.fernet import Fernet, InvalidToken
from fastapi import HTTPException, Request
from fastapi.responses import RedirectResponse
import jwt
@@ -5,6 +7,9 @@
from config import BACKEND_ADMIN_UIDS, BACKEND_JWT_SECRET, HOST_SECURE
+secure_key = Fernet.generate_key()
+
+
def get_auth_id(request: Request) -> str:
"""
Helper function to get auth id (hash) from the request cookie. We use jwt
@@ -68,3 +73,21 @@ def set_auth_id(response: RedirectResponse, uid: str | None):
secure=HOST_SECURE,
samesite="strict",
)
+
+
+def hash_encrypt(reviewer_hash: str):
+ """
+ Converts reviewer hash (identifier associated with reviews) to a id that
+ can be safely sent to the client side.
+ """
+ return Fernet(secure_key).encrypt(base64.b64decode(reviewer_hash)).decode()
+
+
+def hash_decrypt(reviewer_id: str):
+ """
+ Converts a reviewer id to the hash (identifier associated with reviews)
+ """
+ try:
+ return base64.b64encode(Fernet(secure_key).decrypt(reviewer_id)).decode()
+ except InvalidToken:
+ return None
diff --git a/frontend/src/components/Review.jsx b/frontend/src/components/Review.jsx
new file mode 100644
index 0000000..8526765
--- /dev/null
+++ b/frontend/src/components/Review.jsx
@@ -0,0 +1,127 @@
+import React, { useState } from 'react';
+import {
+ Card,
+ CardContent,
+ Tooltip,
+ Typography,
+ Box,
+ Rating,
+ IconButton,
+ useTheme,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Button,
+} from '@mui/material';
+import DeleteIcon from '@mui/icons-material/Delete';
+
+import UpvoteDownvote from './UpvoteDownvote';
+import { api } from '../api';
+
+const Review = ({ review, endpoint, onUpdate }) => {
+ const theme = useTheme(); // Access the theme
+
+ const [openDialog, setOpenDialog] = useState(false); // State for the dialog
+
+ const formattedDate = new Date(review.dtime).toLocaleString();
+
+ const handleDelete = async () => {
+ try {
+ await api.delete(endpoint);
+ await onUpdate();
+ setOpenDialog(false); // Close dialog after deletion
+ } catch (error) {
+ // TODO: convey message to frontend
+ console.error('Error deleting the review:', error);
+ }
+ };
+
+ const handleDialogClose = () => {
+ setOpenDialog(false);
+ };
+
+ const handleDialogOpen = () => {
+ setOpenDialog(true);
+ };
+
+ return (
+ <>
+
+
+ {review.is_reviewer && (
+
+
+ Your review (this line is only visible to you)
+
+
+
+
+
+
+
+ )}
+
+
+
+ {formattedDate}
+
+
+
+ {review.content}
+
+
+
+
+
+ {/* Confirmation Dialog */}
+
+ >
+ );
+};
+
+export default Review;
diff --git a/frontend/src/components/ReviewBox.jsx b/frontend/src/components/ReviewBox.jsx
index b449a8a..c25d68d 100644
--- a/frontend/src/components/ReviewBox.jsx
+++ b/frontend/src/components/ReviewBox.jsx
@@ -3,127 +3,26 @@ import {
Card,
CardContent,
CircularProgress,
+ Tooltip,
Typography,
Box,
- Rating,
IconButton,
- useTheme,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Button,
} from '@mui/material';
-import DeleteIcon from '@mui/icons-material/Delete';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import theme from '../theme';
+import Review from './Review';
import ReviewInput from './ReviewInput';
import { api } from '../api';
-const Review = ({ review, endpoint, onUpdate }) => {
- const theme = useTheme(); // Access the theme
-
- const [openDialog, setOpenDialog] = useState(false); // State for the dialog
-
- const formattedDate = new Date(review.dtime).toLocaleString();
-
- const handleDelete = async () => {
- try {
- await api.delete(endpoint);
- await onUpdate();
- setOpenDialog(false); // Close dialog after deletion
- } catch (error) {
- // TODO: convey message to frontend
- console.error('Error deleting the review:', error);
- }
- };
-
- const handleDialogClose = () => {
- setOpenDialog(false);
- };
-
- const handleDialogOpen = () => {
- setOpenDialog(true);
- };
-
- return (
- <>
-
-
- {review.is_reviewer && (
-
-
- Your review (this line is only visible to you)
-
-
-
-
-
- )}
-
-
-
- {formattedDate}
-
-
-
- {review.content}
-
-
-
-
- {/* Confirmation Dialog */}
-
- >
- );
-};
-
const ReviewBox = ({ children, title, endpoint, initExpanded }) => {
const [reviewsList, setReviewsList] = useState(null);
const [isExpanded, setIsExpanded] = useState(false);
const cache = useRef({}); // Cache for reviews data
const fetchReviews = async () => {
- setReviewsList(null);
try {
const response = await api.get(endpoint);
cache.current[endpoint] = response.data;
@@ -184,9 +83,17 @@ const ReviewBox = ({ children, title, endpoint, initExpanded }) => {
{title}
-
- {isExpanded ? : }
-
+
+
+ {isExpanded ? : }
+
+
{isExpanded && (
<>
@@ -223,7 +130,13 @@ const ReviewBox = ({ children, title, endpoint, initExpanded }) => {
/>
))
)}
-
+ {reviewsList !== null && (
+ review.is_reviewer)}
+ />
+ )}
>
)}
diff --git a/frontend/src/components/ReviewInput.jsx b/frontend/src/components/ReviewInput.jsx
index cf63f69..c127254 100644
--- a/frontend/src/components/ReviewInput.jsx
+++ b/frontend/src/components/ReviewInput.jsx
@@ -10,7 +10,7 @@ import {
import { api } from '../api';
import { MSG_MAX_LEN } from '../constants';
-const ReviewInput = ({ endpoint, onUpdate }) => {
+const ReviewInput = ({ endpoint, onUpdate, hasReview }) => {
const [rating, setRating] = useState(0); // State to hold rating value
const [message, setMessage] = useState(''); // State to hold message
const [isSubmitting, setIsSubmitting] = useState(false); // State to manage form submission state
@@ -81,7 +81,9 @@ const ReviewInput = ({ endpoint, onUpdate }) => {
variant="outlined"
fullWidth
sx={{ marginTop: 1, marginBottom: 2 }}
- inputProps={{ maxLength: MSG_MAX_LEN }} // Character limit set here
+ slotProps={{
+ htmlInput: { maxLength: MSG_MAX_LEN }, // Character limit set here
+ }}
/>
{
-
- To discourage spam, only one review per user per course/professor is
- allowed. If you have previously posted a review here, it will be
- overwritten if you resubmit a new review.
-
+ {hasReview && (
+
+ To discourage spam, only one review per user per course/professor is
+ allowed. As you have already posted a review here, it will be
+ overwritten if you resubmit a new review.
+
+ )}
>
);
};
diff --git a/frontend/src/components/UpvoteDownvote.jsx b/frontend/src/components/UpvoteDownvote.jsx
new file mode 100644
index 0000000..9fc6ea2
--- /dev/null
+++ b/frontend/src/components/UpvoteDownvote.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { IconButton, Tooltip, Typography, Box } from '@mui/material';
+import ThumbUpIcon from '@mui/icons-material/ThumbUp';
+import ThumbDownIcon from '@mui/icons-material/ThumbDown';
+
+import { api } from '../api';
+
+const UpvoteDownvote = ({ review, endpoint, onUpdate }) => {
+ const sendVoteToAPI = async (vote) => {
+ try {
+ await api.post(`${endpoint}/votes`, {
+ vote: vote,
+ review_id: review.review_id,
+ });
+ await onUpdate();
+ } catch (error) {
+ console.error('Failed to send vote', error);
+ }
+ };
+
+ const isSelected = (newStatus) => {
+ return review.votes_status === newStatus;
+ };
+
+ const handleVote = async (newStatus) => {
+ if (isSelected(newStatus)) {
+ await sendVoteToAPI(0);
+ } else {
+ await sendVoteToAPI(newStatus);
+ }
+ };
+
+ return (
+
+
+ handleVote(1)}
+ color={isSelected(1) ? 'success' : 'default'}
+ size="small"
+ >
+
+
+
+
+ {review.votes_aggregate}
+
+
+ handleVote(-1)}
+ color={review.votes_status === -1 ? 'error' : 'default'}
+ size="small"
+ >
+
+
+
+
+ );
+};
+
+export default UpvoteDownvote;