From 2974d5d967d475fcee97358d31c9f8a340db4d35 Mon Sep 17 00:00:00 2001 From: Himesh Ahuja Date: Sat, 29 Nov 2025 19:51:52 -0500 Subject: [PATCH 01/23] upvote downvote still buggy --- tcf_website/models/__init__.py | 1 + tcf_website/models/models.py | 110 ++++++++ tcf_website/static/reviews/review.css | 8 + tcf_website/templates/reviews/review.html | 312 +++++++++++++++------ tcf_website/templates/reviews/reviews.html | 22 +- tcf_website/urls.py | 4 + tcf_website/views/review.py | 59 +++- 7 files changed, 429 insertions(+), 87 deletions(-) diff --git a/tcf_website/models/__init__.py b/tcf_website/models/__init__.py index c6e44d132..101bb59ee 100644 --- a/tcf_website/models/__init__.py +++ b/tcf_website/models/__init__.py @@ -17,6 +17,7 @@ Instructor, Question, Review, + Reply, Schedule, ScheduledCourse, School, diff --git a/tcf_website/models/models.py b/tcf_website/models/models.py index 73f4fcee3..9996a9ae0 100644 --- a/tcf_website/models/models.py +++ b/tcf_website/models/models.py @@ -1385,6 +1385,116 @@ class Meta: # ) # ] +class Reply(models.Model): + """Reply model. + Belongs to a user + Has a review + """ + + text = models.TextField() + review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name="replies") + user = models.ForeignKey(User, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "review"], + name="unique reply per user and review", + ) + ] + + def __str__(self): + return f"Reply by {self.user.first_name} ({self.user.email}) to {self.review}" + + def count_votes(self): + """Sum votes for review.""" + return self.votereply_set.aggregate( + upvotes=Coalesce(models.Sum("value", filter=models.Q(value=1)), 0), + downvotes=Coalesce(Abs(models.Sum("value", filter=models.Q(value=-1))), 0), + ) + + def upvote(self, user): + """Create an upvote.""" + + # Check if already upvoted. + upvoted = VoteReply.objects.filter( + user=user, + reply=self, + value=1, + ).exists() + + # Delete all prior votes. + VoteReply.objects.filter( + user=user, + reply=self, + ).delete() + + # Don't upvote again if previously upvoted. + if upvoted: + return + + VoteReply.objects.create( + value=1, + user=user, + reply=self, + ) + + def downvote(self, user): + """Create a downvote.""" + + # Check if already downvoted. + downvoted = VoteReply.objects.filter( + user=user, + reply=self, + value=-1, + ).exists() + + # Delete all prior votes. + VoteReply.objects.filter( + user=user, + reply=self, + ).delete() + + # Don't downvote again if previously downvoted. + if downvoted: + return + + VoteReply.objects.create( + value=-1, + user=user, + reply=self, + ) + +class VoteReply(models.Model): + """VoteReply model. + Belongs to a User. + Has a reply. + """ + + # Vote value. Required. + value = models.IntegerField( + validators=[MinValueValidator(-1), MaxValueValidator(1)] + ) + # Vote user foreign key. Required. + user = models.ForeignKey(User, on_delete=models.CASCADE) + # Vote review foreign key. Required. + reply = models.ForeignKey(Reply, on_delete=models.CASCADE) + + def __str__(self): + return f"Vote of value {self.value} for {self.reply} by {self.user}" + + class Meta: + indexes = [ + models.Index(fields=["reply"]), + ] + + constraints = [ + models.UniqueConstraint( + fields=["user", "reply"], + name="unique vote per user and reply", + ) + ] class Vote(models.Model): """Vote model. diff --git a/tcf_website/static/reviews/review.css b/tcf_website/static/reviews/review.css index 43566f1bd..275cc9fb4 100644 --- a/tcf_website/static/reviews/review.css +++ b/tcf_website/static/reviews/review.css @@ -41,3 +41,11 @@ .tooltip-inner { margin: 0; } + +.longtext { + min-width:0; + white-space:normal; + word-break:break-word; + overflow-wrap:break-word; + word-wrap:break-word; +} diff --git a/tcf_website/templates/reviews/review.html b/tcf_website/templates/reviews/review.html index 7a9e090e2..998d57b6a 100644 --- a/tcf_website/templates/reviews/review.html +++ b/tcf_website/templates/reviews/review.html @@ -12,109 +12,255 @@
- - - - - - - {% include "reviews/delete_confirm_modal.html" with review_id=review.id %} + + + + + + + {% include "reviews/delete_confirm_modal.html" with review_id=review.id %}
{% else %}
-
-
{{ review.semester }}
-
Updated {{ review.created|date:"n/d/y" }}
-
+
+
{{ review.semester }}
+
Updated {{ review.created|date:"n/d/y" }}
+
{% endif %} -
- - - - + + + +
- - + +
+
+ {% for reply in review.replies.all %} +
+
+ {{ reply.text | linebreaks }} +
+
+
+
+ {% if user.is_authenticated %} + + + + {% else %} + + + + {% endif %} + {{ reply.count_votes.upvotes }} +
+
+ {% if user.is_authenticated %} + + + + {% else %} + + + + {% endif %} + {{ reply.count_votes.downvotes }} +
+
+ {% if user == reply.user %} + + + + {% include "reviews/delete_reply_confirm_modal.html" with reply_id=reply.id %} + {% endif %} +
+
+ {% endfor %} +
+
\ No newline at end of file diff --git a/tcf_website/templates/reviews/reviews.html b/tcf_website/templates/reviews/reviews.html index 369fb289f..93a5ac0b7 100644 --- a/tcf_website/templates/reviews/reviews.html +++ b/tcf_website/templates/reviews/reviews.html @@ -78,18 +78,34 @@

+ \ No newline at end of file diff --git a/tcf_website/urls.py b/tcf_website/urls.py index 7ed8ee62c..44e848ede 100644 --- a/tcf_website/urls.py +++ b/tcf_website/urls.py @@ -58,6 +58,10 @@ views.DeleteReview.as_view(), name="delete_review", ), + path("reviews//reply/", views.review.new_reply, name="reply"), + path("reviews/reply//upvote/", views.review.upvote_reply, name="upvote_reply"), + path("reviews/reply//downvote/", views.review.downvote_reply, name="downvote_reply"), + path("reviews/reply//delete/", views.review.delete_reply, name="delete_reply"), path("reviews//edit/", views.edit_review, name="edit_review"), path("reviews/", views.reviews, name="reviews"), path("reviews//upvote/", views.upvote), diff --git a/tcf_website/views/review.py b/tcf_website/views/review.py index 3fd02ec02..5e27ef6a8 100644 --- a/tcf_website/views/review.py +++ b/tcf_website/views/review.py @@ -1,5 +1,6 @@ """View pertaining to review creation/viewing.""" +from django.db import IntegrityError from django import forms from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -11,7 +12,7 @@ from django.urls import reverse_lazy from django.views import generic -from ..models import Review, ClubCategory, Club +from ..models import Review, Reply, ClubCategory, Club # pylint: disable=fixme,unused-argument # Disable pylint errors on TODO messages, such as below @@ -281,3 +282,59 @@ def edit_review(request, review_id): return render(request, "reviews/edit_review.html", {"form": form}) form = ReviewForm(instance=review) return render(request, "reviews/edit_review.html", {"form": form}) + + +class ReplyForm(forms.ModelForm): + class Meta: + model = Reply + fields = ["text"] + +@login_required +def new_reply(request, review_id): + review = get_object_or_404(Review, pk=review_id) + if request.method == "POST": + form = ReplyForm(request.POST) + if form.is_valid(): + reply = form.save(commit=False) + reply.review = review + reply.user = request.user + try: + reply.save() + messages.success(request, "Reply posted.") + except IntegrityError: + messages.error(request, "You have already replied to this review.") + else: + form = ReplyForm() + + return redirect(f"/course/{review.course.id}/{review.instructor.id}") + + +@login_required +def delete_reply(request, reply_id): + reply = get_object_or_404(Reply, pk=reply_id) + if reply.user != request.user: + raise PermissionDenied("You are not allowed to delete this reply!") + + review = reply.review + reply.delete() + messages.success(request, "Reply deleted.") + return redirect(f"/course/{review.course.id}/{review.instructor.id}") + +@login_required +def upvote_reply(request, reply_id): + """Upvote a view.""" + if request.method == "POST": + reply = Reply.objects.get(pk=reply_id) + reply.upvote(request.user) + return JsonResponse({"ok": True}) + return JsonResponse({"ok": False}) + + +@login_required +def downvote_reply(request, reply_id): + """Downvote a view.""" + if request.method == "POST": + reply = Reply.objects.get(pk=reply_id) + reply.downvote(request.user) + return JsonResponse({"ok": True}) + return JsonResponse({"ok": False}) \ No newline at end of file From d8a69549db7779014d103da8946020c66b0d3098 Mon Sep 17 00:00:00 2001 From: Himesh A <83563033+meshhi13@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:54:56 -0500 Subject: [PATCH 02/23] Update tcf_website/models/models.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tcf_website/models/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcf_website/models/models.py b/tcf_website/models/models.py index 9996a9ae0..e8b111930 100644 --- a/tcf_website/models/models.py +++ b/tcf_website/models/models.py @@ -1391,7 +1391,7 @@ class Reply(models.Model): Has a review """ - text = models.TextField() + text = models.TextField(max_length=5000) review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name="replies") user = models.ForeignKey(User, on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) From 840bfd5ee959a9f5c531130779f592c3a9322f42 Mon Sep 17 00:00:00 2001 From: Himesh Ahuja Date: Sat, 29 Nov 2025 20:55:21 -0500 Subject: [PATCH 03/23] Code rabbit issues --- .../0024_reply_votereply_and_more.py | 103 ++++++++++++++++++ tcf_website/models/models.py | 4 +- tcf_website/static/reviews/reply.js | 66 +++++++++++ .../reviews/delete_reply_confirm_modal.html | 25 +++++ tcf_website/templates/reviews/review.html | 2 +- tcf_website/views/browse.py | 21 ++++ tcf_website/views/review.py | 16 ++- 7 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 tcf_website/migrations/0024_reply_votereply_and_more.py create mode 100644 tcf_website/static/reviews/reply.js create mode 100644 tcf_website/templates/reviews/delete_reply_confirm_modal.html diff --git a/tcf_website/migrations/0024_reply_votereply_and_more.py b/tcf_website/migrations/0024_reply_votereply_and_more.py new file mode 100644 index 000000000..bb133d6ae --- /dev/null +++ b/tcf_website/migrations/0024_reply_votereply_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.26 on 2025-11-29 19:40 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("tcf_website", "0023_remove_sectionenrollment_section_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Reply", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "review", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="tcf_website.review", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="VoteReply", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "value", + models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(-1), + django.core.validators.MaxValueValidator(1), + ] + ), + ), + ( + "reply", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="tcf_website.reply", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["reply"], name="tcf_website_reply_i_f31693_idx" + ) + ], + }, + ), + migrations.AddConstraint( + model_name="votereply", + constraint=models.UniqueConstraint( + fields=("user", "reply"), name="unique vote per user and reply" + ), + ), + migrations.AddConstraint( + model_name="reply", + constraint=models.UniqueConstraint( + fields=("user", "review"), name="unique reply per user and review" + ), + ), + ] diff --git a/tcf_website/models/models.py b/tcf_website/models/models.py index e8b111930..c369be850 100644 --- a/tcf_website/models/models.py +++ b/tcf_website/models/models.py @@ -1473,9 +1473,7 @@ class VoteReply(models.Model): """ # Vote value. Required. - value = models.IntegerField( - validators=[MinValueValidator(-1), MaxValueValidator(1)] - ) + value = models.IntegerField(choices=[(-1, 'Downvote'), (1, 'Upvote')]) # Vote user foreign key. Required. user = models.ForeignKey(User, on_delete=models.CASCADE) # Vote review foreign key. Required. diff --git a/tcf_website/static/reviews/reply.js b/tcf_website/static/reviews/reply.js new file mode 100644 index 000000000..a6a1f211a --- /dev/null +++ b/tcf_website/static/reviews/reply.js @@ -0,0 +1,66 @@ +/* For reply upvote/downvote functionality */ +function handleReplyVote(replyID, isUpvote) { + const upvoteCountElem = $(`#reply${replyID} .reply-upvote-count`); + const downvoteCountElem = $(`#reply${replyID} .reply-downvote-count`); + const upvoteCount = parseInt(upvoteCountElem.text()); + const downvoteCount = parseInt(downvoteCountElem.text()); + + + let elem; + let otherElem; + let endpoint; + let newUpvoteCount = upvoteCount; + let newDownvoteCount = downvoteCount; + + + if (isUpvote) { + elem = $(`#reply${replyID} .reply-upvote`); + otherElem = $(`#reply${replyID} .reply-downvote`); + endpoint = `/reviews/reply/${replyID}/upvote/`; + + + if (elem.hasClass("active")) { + newUpvoteCount = upvoteCount - 1; + } else if (otherElem.hasClass("active")) { + newUpvoteCount = upvoteCount + 1; + newDownvoteCount = downvoteCount - 1; + } else { + newUpvoteCount = upvoteCount + 1; + } + } else { + elem = $(`#reply${replyID} .reply-downvote`); + otherElem = $(`#reply${replyID} .reply-upvote`); + endpoint = `/reviews/reply/${replyID}/downvote/`; + + + if (elem.hasClass("active")) { + newDownvoteCount = downvoteCount - 1; + } else if (otherElem.hasClass("active")) { + newDownvoteCount = downvoteCount + 1; + newUpvoteCount = upvoteCount - 1; + } else { + newDownvoteCount = downvoteCount + 1; + } + } + + + fetch(endpoint, { + method: "post", + headers: { "X-CSRFToken": getCookie("csrftoken") }, + }); + + + upvoteCountElem.text(newUpvoteCount); + downvoteCountElem.text(newDownvoteCount); + + + if (elem.hasClass("active")) { + elem.removeClass("active"); + } else { + elem.addClass("active"); + otherElem.removeClass("active"); + } +} + + +export { handleReplyVote }; \ No newline at end of file diff --git a/tcf_website/templates/reviews/delete_reply_confirm_modal.html b/tcf_website/templates/reviews/delete_reply_confirm_modal.html new file mode 100644 index 000000000..312751996 --- /dev/null +++ b/tcf_website/templates/reviews/delete_reply_confirm_modal.html @@ -0,0 +1,25 @@ + + + + diff --git a/tcf_website/templates/reviews/review.html b/tcf_website/templates/reviews/review.html index 998d57b6a..1237d51bb 100644 --- a/tcf_website/templates/reviews/review.html +++ b/tcf_website/templates/reviews/review.html @@ -91,7 +91,7 @@
Updated {{ review.created|date:"n/d/y" }}
- {% if user.is_authenticated and user.email != instructor.email %} + {% if user.is_authenticated %} diff --git a/tcf_website/views/browse.py b/tcf_website/views/browse.py index 7f1503b0d..f56d6cca7 100644 --- a/tcf_website/views/browse.py +++ b/tcf_website/views/browse.py @@ -16,6 +16,7 @@ Sum, Value, ) +from django.db.models.query import prefetch_related_objects from django.db.models.functions import Coalesce from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render @@ -30,6 +31,7 @@ Department, Instructor, Question, + Reply, Review, School, Section, @@ -335,6 +337,25 @@ def course_instructor(request, course_id, instructor_id, method="Default"): course_id, instructor_id, request.user, page_number, method ) + replies_queryset = Reply.objects.select_related("user").order_by("created") + if request.user.is_authenticated: + replies_queryset = replies_queryset.annotate( + user_vote=Coalesce( + Sum( + "votereply__value", + filter=Q(votereply__user=request.user), + ), + Value(0), + ) + ) + else: + replies_queryset = replies_queryset.annotate(user_vote=Value(0)) + + prefetch_related_objects( + paginated_reviews.object_list, + Prefetch("replies", queryset=replies_queryset), + ) + course_url = reverse("course", args=[course.subdepartment.mnemonic, course.number]) # Navigation breadcrumbs breadcrumbs = [ diff --git a/tcf_website/views/review.py b/tcf_website/views/review.py index 5e27ef6a8..e6af544db 100644 --- a/tcf_website/views/review.py +++ b/tcf_website/views/review.py @@ -285,12 +285,14 @@ def edit_review(request, review_id): class ReplyForm(forms.ModelForm): + """Form for reply creation.""" class Meta: model = Reply fields = ["text"] @login_required def new_reply(request, review_id): + """Create a new reply to a review.""" review = get_object_or_404(Review, pk=review_id) if request.method == "POST": form = ReplyForm(request.POST) @@ -306,11 +308,12 @@ def new_reply(request, review_id): else: form = ReplyForm() - return redirect(f"/course/{review.course.id}/{review.instructor.id}") + return redirect('course_instructor', course_id=review.course.id, instructor_id=review.instructor.id) @login_required def delete_reply(request, reply_id): + """Delete a reply.""" reply = get_object_or_404(Reply, pk=reply_id) if reply.user != request.user: raise PermissionDenied("You are not allowed to delete this reply!") @@ -318,13 +321,14 @@ def delete_reply(request, reply_id): review = reply.review reply.delete() messages.success(request, "Reply deleted.") - return redirect(f"/course/{review.course.id}/{review.instructor.id}") + + return redirect('course_instructor', course_id=review.course.id, instructor_id=review.instructor.id) @login_required def upvote_reply(request, reply_id): - """Upvote a view.""" + """Upvote a reply.""" if request.method == "POST": - reply = Reply.objects.get(pk=reply_id) + reply = get_object_or_404(Reply, pk=reply_id) reply.upvote(request.user) return JsonResponse({"ok": True}) return JsonResponse({"ok": False}) @@ -332,9 +336,9 @@ def upvote_reply(request, reply_id): @login_required def downvote_reply(request, reply_id): - """Downvote a view.""" + """Downvote a reply.""" if request.method == "POST": - reply = Reply.objects.get(pk=reply_id) + reply = get_object_or_404(Reply, pk=reply_id) reply.downvote(request.user) return JsonResponse({"ok": True}) return JsonResponse({"ok": False}) \ No newline at end of file From 75d2647edc721a40894bb23c161645a7253565eb Mon Sep 17 00:00:00 2001 From: Himesh A <83563033+meshhi13@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:10:58 -0500 Subject: [PATCH 04/23] Update tcf_website/templates/reviews/delete_reply_confirm_modal.html Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tcf_website/templates/reviews/delete_reply_confirm_modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcf_website/templates/reviews/delete_reply_confirm_modal.html b/tcf_website/templates/reviews/delete_reply_confirm_modal.html index 312751996..26a75c4b7 100644 --- a/tcf_website/templates/reviews/delete_reply_confirm_modal.html +++ b/tcf_website/templates/reviews/delete_reply_confirm_modal.html @@ -1,5 +1,5 @@ -