Skip to content

Commit

Permalink
Implement upvote/downvote feature, misc fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
ankith26 committed Dec 16, 2024
1 parent 839bdd1 commit 61dc71b
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 137 deletions.
38 changes: 34 additions & 4 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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):
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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
22 changes: 17 additions & 5 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
63 changes: 58 additions & 5 deletions backend/routes/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down Expand Up @@ -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
]


Expand All @@ -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()]
}
}
}
],
)


Expand All @@ -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
}
},
)
55 changes: 50 additions & 5 deletions backend/routes/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down Expand Up @@ -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
]


Expand All @@ -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()]
}
}
}
],
)


Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions backend/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import base64
from cryptography.fernet import Fernet, InvalidToken
from fastapi import HTTPException, Request
from fastapi.responses import RedirectResponse
import jwt

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
Expand Down Expand Up @@ -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
Loading

0 comments on commit 61dc71b

Please sign in to comment.