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 */} + + Confirm Deletion + + Are you sure you want to delete this review? + + + + + + + + ); +}; + +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 */} - - Confirm Deletion - - Are you sure you want to delete this review? - - - - - - - - ); -}; - 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;