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 */}
+
+ >
);
};
@@ -86,10 +165,9 @@ const ReviewBox = ({ children, endpoint }) => {
) : (
reviewsList.map((review, index) => (
))
)}