Skip to content

Commit 9fb6eb3

Browse files
committed
Implement upvote/downvote feature, misc fixes
1 parent 839bdd1 commit 9fb6eb3

File tree

9 files changed

+413
-137
lines changed

9 files changed

+413
-137
lines changed

backend/models.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
# TODO: can make this regex more precise
2727
StudentRollno: TypeAlias = Annotated[str, StringConstraints(pattern=r"^\d{10}$")]
2828

29+
# A vote can be 1 (upvote), -1 (downvote) or 0 (no vote)
30+
Vote: TypeAlias = Literal[-1, 0, 1]
31+
2932

3033
class Review(BaseModel):
3134
"""
@@ -36,10 +39,6 @@ class Review(BaseModel):
3639
content: str = Field(..., min_length=1, max_length=MSG_MAX_LEN)
3740
dtime: AwareDatetime = Field(default_factory=lambda: datetime.now(timezone.utc))
3841

39-
# TODO: upvote/downvote system
40-
# upvoters: set[str] # set of student emails
41-
# downvoters: set[str] # set of student emails
42-
4342
# Model-level validator that runs before individual field validation
4443
@model_validator(mode="before")
4544
def convert_naive_to_aware(cls, values):
@@ -50,14 +49,36 @@ def convert_naive_to_aware(cls, values):
5049
return values
5150

5251

52+
class ReviewBackend(Review):
53+
"""
54+
This represents a Review as it is stored in the backend (db).
55+
"""
56+
57+
# mapping from student hash to vote.
58+
# this dict is not to be exposed to the frontend directly, as the hashes
59+
# must not be exposed.
60+
votes: dict[str, Vote] = Field(default_factory=dict)
61+
62+
5363
class ReviewFrontend(Review):
5464
"""
5565
This represents a Review as it is seen from the frontend. Some attributes
5666
with the backend are common, but some are not.
5767
"""
5868

69+
# The id of the Review as visible to the frontend. This is the encrypted
70+
# reviewer hash.
71+
review_id: str
72+
73+
# stores whether viewer is the author of the review
5974
is_reviewer: bool
6075

76+
# aggregate of votes
77+
votes_aggregate: int
78+
79+
# stores the upvote/downvote status of the author
80+
votes_status: Vote
81+
6182

6283
class Member(BaseModel):
6384
"""
@@ -92,3 +113,12 @@ class Course(BaseModel):
92113
sem: Sem
93114
name: str = Field(..., min_length=1)
94115
profs: list[EmailStr] # list of prof emails
116+
117+
118+
class VoteAndReviewID(BaseModel):
119+
"""
120+
Base class for storing a vote and review_id (used in post body for vote API)
121+
"""
122+
123+
vote: Vote
124+
review_id: str

backend/requirements.txt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,36 @@
1+
# To regen this file: run the following in a fresh venv
2+
# pip install fastapi email-validator uvicorn[standard] pyjwt cryptography motor python-cas
3+
# pip freeze
14
annotated-types==0.7.0
2-
anyio==4.6.2.post1
3-
certifi==2024.8.30
5+
anyio==4.7.0
6+
certifi==2024.12.14
7+
cffi==1.17.1
48
charset-normalizer==3.4.0
59
click==8.1.7
10+
cryptography==44.0.0
611
dnspython==2.7.0
712
email_validator==2.2.0
8-
fastapi==0.115.5
13+
fastapi==0.115.6
914
h11==0.14.0
15+
httptools==0.6.4
1016
idna==3.10
1117
lxml==5.3.0
1218
motor==3.6.0
19+
pycparser==2.22
1320
pydantic==2.10.3
1421
pydantic_core==2.27.1
1522
PyJWT==2.10.1
1623
pymongo==4.9.2
1724
python-cas==1.6.0
25+
python-dotenv==1.0.1
26+
PyYAML==6.0.2
1827
requests==2.32.3
19-
six==1.16.0
28+
six==1.17.0
2029
sniffio==1.3.1
2130
starlette==0.41.3
2231
typing_extensions==4.12.2
2332
urllib3==2.2.3
24-
uvicorn==0.32.1
33+
uvicorn==0.34.0
34+
uvloop==0.21.0
35+
watchfiles==1.0.3
36+
websockets==14.1

backend/routes/courses.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@
44

55
from routes.members import prof_exists
66
from config import db
7-
from utils import get_auth_id, get_auth_id_admin
8-
from models import Course, Review, ReviewFrontend, Sem, CourseCode
7+
from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
8+
from models import (
9+
Course,
10+
Review,
11+
ReviewBackend,
12+
ReviewFrontend,
13+
Sem,
14+
CourseCode,
15+
VoteAndReviewID,
16+
)
917

1018
# The get_auth_id Dependency validates authentication of the caller
1119
router = APIRouter(dependencies=[Depends(get_auth_id)])
@@ -87,9 +95,21 @@ async def course_reviews_get(
8795
if not course_reviews:
8896
return None
8997

98+
course_reviews_validated = [
99+
(k, ReviewBackend(**v)) for k, v in course_reviews.get("reviews", {}).items()
100+
]
101+
90102
return [
91-
ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump()
92-
for k, v in course_reviews.get("reviews", {}).items()
103+
ReviewFrontend(
104+
rating=v.rating,
105+
content=v.content,
106+
dtime=v.dtime,
107+
review_id=hash_encrypt(k),
108+
is_reviewer=(k == auth_id),
109+
votes_aggregate=sum(v.votes.values()),
110+
votes_status=v.votes.get(auth_id, 0),
111+
).model_dump()
112+
for k, v in course_reviews_validated
93113
]
94114

95115

@@ -104,7 +124,16 @@ async def course_reviews_post(
104124
"""
105125
await course_collection.update_one(
106126
{"sem": sem, "code": code},
107-
{"$set": {f"reviews.{auth_id}": review.model_dump()}},
127+
[
128+
{
129+
"$set": {
130+
# do merge objects to keep old votes intact
131+
f"reviews.{auth_id}": {
132+
"$mergeObjects": [f"$reviews.{auth_id}", review.model_dump()]
133+
}
134+
}
135+
}
136+
],
108137
)
109138

110139

@@ -120,3 +149,27 @@ async def course_reviews_delete(
120149
{"sem": sem, "code": code},
121150
{"$unset": {f"reviews.{auth_id}": ""}}
122151
)
152+
153+
154+
@router.post("/reviews/{sem}/{code}/votes")
155+
async def course_reviews_votes_post(
156+
sem: Sem,
157+
code: CourseCode,
158+
post_body: VoteAndReviewID,
159+
auth_id: str = Depends(get_auth_id),
160+
):
161+
"""
162+
Helper to post a vote on a single Review on a Course.
163+
"""
164+
review_hash = hash_decrypt(post_body.review_id)
165+
if not review_hash:
166+
raise HTTPException(422, "Invalid review_id value")
167+
168+
await course_collection.update_one(
169+
{"sem": sem, "code": code},
170+
{
171+
"$set" if post_body.vote else "$unset": {
172+
f"reviews.{review_hash}.votes.{auth_id}": post_body.vote
173+
}
174+
},
175+
)

backend/routes/members.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from pydantic import EmailStr
66

77
from config import db
8-
from utils import get_auth_id, get_auth_id_admin
9-
from models import Prof, Review, ReviewFrontend, Student
8+
from utils import get_auth_id, get_auth_id_admin, hash_decrypt, hash_encrypt
9+
from models import Prof, Review, ReviewBackend, ReviewFrontend, Student, VoteAndReviewID
1010

1111
# The get_auth_id Dependency validates authentication of the caller
1212
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))
6565
if not prof_reviews:
6666
return None
6767

68+
prof_reviews_validated = [
69+
(k, ReviewBackend(**v)) for k, v in prof_reviews.get("reviews", {}).items()
70+
]
71+
6872
return [
69-
ReviewFrontend(**v, is_reviewer=(k == auth_id)).model_dump()
70-
for k, v in prof_reviews.get("reviews", {}).items()
73+
ReviewFrontend(
74+
rating=v.rating,
75+
content=v.content,
76+
dtime=v.dtime,
77+
review_id=hash_encrypt(k),
78+
is_reviewer=(k == auth_id),
79+
votes_aggregate=sum(v.votes.values()),
80+
votes_status=v.votes.get(auth_id, 0),
81+
).model_dump()
82+
for k, v in prof_reviews_validated
7183
]
7284

7385

@@ -81,7 +93,17 @@ async def prof_reviews_post(
8193
review discards any older reviews.
8294
"""
8395
await profs_collection.update_one(
84-
{"email": email}, {"$set": {f"reviews.{auth_id}": review.model_dump()}}
96+
{"email": email},
97+
[
98+
{
99+
"$set": {
100+
# do merge objects to keep old votes intact
101+
f"reviews.{auth_id}": {
102+
"$mergeObjects": [f"$reviews.{auth_id}", review.model_dump()]
103+
}
104+
}
105+
}
106+
],
85107
)
86108

87109

@@ -96,6 +118,29 @@ async def prof_reviews_delete(email: EmailStr, auth_id: str = Depends(get_auth_i
96118
)
97119

98120

121+
@router.post("/reviews/{email}/votes")
122+
async def course_reviews_votes_post(
123+
email: EmailStr,
124+
post_body: VoteAndReviewID,
125+
auth_id: str = Depends(get_auth_id),
126+
):
127+
"""
128+
Helper to post a vote on a single Review on a Prof.
129+
"""
130+
review_hash = hash_decrypt(post_body.review_id)
131+
if not review_hash:
132+
raise HTTPException(422, "Invalid review_id value")
133+
134+
await profs_collection.update_one(
135+
{"email": email},
136+
{
137+
"$set" if post_body.vote else "$unset": {
138+
f"reviews.{review_hash}.votes.{auth_id}": post_body.vote
139+
}
140+
},
141+
)
142+
143+
99144
async def student_hash(user: Student):
100145
"""
101146
Internal function to hash a Student object. This hash is used as a review key

backend/utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import base64
2+
from cryptography.fernet import Fernet, InvalidToken
13
from fastapi import HTTPException, Request
24
from fastapi.responses import RedirectResponse
35
import jwt
46

57
from config import BACKEND_ADMIN_UIDS, BACKEND_JWT_SECRET, HOST_SECURE
68

79

10+
secure_key = Fernet.generate_key()
11+
12+
813
def get_auth_id(request: Request) -> str:
914
"""
1015
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):
6873
secure=HOST_SECURE,
6974
samesite="strict",
7075
)
76+
77+
78+
def hash_encrypt(reviewer_hash: str):
79+
"""
80+
Converts reviewer hash (identifier associated with reviews) to a id that
81+
can be safely sent to the client side.
82+
"""
83+
return Fernet(secure_key).encrypt(base64.b64decode(reviewer_hash)).decode()
84+
85+
86+
def hash_decrypt(reviewer_id: str):
87+
"""
88+
Converts a reviewer id to the hash (identifier associated with reviews)
89+
"""
90+
try:
91+
return base64.b64encode(Fernet(secure_key).decrypt(reviewer_id)).decode()
92+
except InvalidToken:
93+
return None

0 commit comments

Comments
 (0)