diff --git a/backend/models.py b/backend/models.py index a9276b1..e642ab1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -35,6 +35,7 @@ class Review(BaseModel): rating: Literal[1, 2, 3, 4, 5] 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 @@ -49,6 +50,15 @@ def convert_naive_to_aware(cls, values): return values +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. + """ + + is_reviewer: bool + + class Member(BaseModel): """ Base class for representing a Member, can be a Student or Prof diff --git a/backend/routes/courses.py b/backend/routes/courses.py index 8329033..7b59c16 100644 --- a/backend/routes/courses.py +++ b/backend/routes/courses.py @@ -5,7 +5,7 @@ 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, Sem, CourseCode +from models import Course, Review, ReviewFrontend, Sem, CourseCode # The get_auth_id Dependency validates authentication of the caller router = APIRouter(dependencies=[Depends(get_auth_id)]) @@ -19,7 +19,7 @@ async def course_list( prof_filter: EmailStr | None = None, ): """ - List all courses. + List all courses. This does not return the reviews attribute, that must be queried individually. Can optionally pass filters for: - course semester @@ -74,7 +74,9 @@ async def course_post(courses: list[Course]): @router.get("/reviews/{sem}/{code}") -async def course_reviews_get(sem: Sem, code: CourseCode): +async def course_reviews_get( + sem: Sem, code: CourseCode, auth_id: str = Depends(get_auth_id) +): """ Helper to return all reviews under a given course. This function returns None if the course does not exist @@ -86,7 +88,8 @@ async def course_reviews_get(sem: Sem, code: CourseCode): return None return [ - Review(**i).model_dump() for i in course_reviews.get("reviews", {}).values() + ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump() + for k, v in course_reviews.get("reviews", {}).items() ] @@ -103,3 +106,17 @@ async def course_reviews_post( {"sem": sem, "code": code}, {"$set": {f"reviews.{auth_id}": review.model_dump()}}, ) + + +@router.delete("/reviews/{sem}/{code}") +async def course_reviews_delete( + sem: Sem, code: CourseCode, auth_id: str = Depends(get_auth_id) +): + """ + Helper to delete a review posted by the authenticated user on a professor. + 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}": ""}} + ) diff --git a/backend/routes/members.py b/backend/routes/members.py index a4682a2..c282f22 100644 --- a/backend/routes/members.py +++ b/backend/routes/members.py @@ -6,7 +6,7 @@ from config import db from utils import get_auth_id, get_auth_id_admin -from models import Prof, Review, Student +from models import Prof, Review, ReviewFrontend, Student # The get_auth_id Dependency validates authentication of the caller router = APIRouter(dependencies=[Depends(get_auth_id)]) @@ -54,7 +54,7 @@ async def prof_post(profs: list[Prof]): @router.get("/reviews/{email}") -async def prof_reviews_get(email: EmailStr): +async def prof_reviews_get(email: EmailStr, auth_id: str = Depends(get_auth_id)): """ Helper to return all reviews under a given Prof email. This function returns None if the prof does not exist @@ -65,7 +65,10 @@ async def prof_reviews_get(email: EmailStr): if not prof_reviews: return None - return [Review(**i).model_dump() for i in prof_reviews.get("reviews", {}).values()] + return [ + ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump() + for k, v in prof_reviews.get("reviews", {}).items() + ] @router.post("/reviews/{email}") @@ -82,6 +85,17 @@ async def prof_reviews_post( ) +@router.delete("/reviews/{email}") +async def prof_reviews_delete(email: EmailStr, auth_id: str = Depends(get_auth_id)): + """ + Helper to delete a review posted by the authenticated user on a professor. + If the user hasn't posted a review, no action will be taken. + """ + await profs_collection.update_one( + {"email": email}, {"$unset": {f"reviews.{auth_id}": ""}} + ) + + async def student_hash(user: Student): """ Internal function to hash a Student object. This hash is used as a review key diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 47a579d..b4652ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", "@vitejs/plugin-react": "^4.3.4", "axios": "^1.7.9", @@ -1059,6 +1060,31 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.2.0.tgz", + "integrity": "sha512-WR1EEhGOSvxAsoTSzWZBlrWFjul8wziDrII4rC3PvMBHhBYBqEc2n/0aamfFbwkH5EiYb96aqc6kYY6tB310Sw==", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f5926a1..ee86a40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", "@vitejs/plugin-react": "^4.3.4", "axios": "^1.7.9", diff --git a/frontend/src/components/ReviewBox.jsx b/frontend/src/components/ReviewBox.jsx index 1218129..59085e6 100644 --- a/frontend/src/components/ReviewBox.jsx +++ b/frontend/src/components/ReviewBox.jsx @@ -6,33 +6,112 @@ import { Typography, Box, Rating, + IconButton, + useTheme, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Button, } from '@mui/material'; +import DeleteIcon from "@mui/icons-material/Delete"; import theme from '../theme'; import ReviewInput from './ReviewInput'; import { api } from '../api'; -const Review = ({ datetime, rating, message }) => { - const formattedDate = new Date(datetime).toLocaleString(); + +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 ( - - - - - - {formattedDate} + <> + + + {review.is_reviewer && ( + + + Your review (this is only visible to you) + + + + + + )} + + + + {formattedDate} + + + + {review.content} - - - {message} - - - + + + + {/* Confirmation Dialog */} + + Confirm Deletion + + Are you sure you want to delete this review? + + + + + + + ); }; @@ -86,10 +165,9 @@ const ReviewBox = ({ children, endpoint }) => { ) : ( reviewsList.map((review, index) => ( )) )}