Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
"testing.apps.TestingConfig",
"django_probes",
"dissertations.apps.DissertationsConfig",
"testimonials.apps.TestimonialsConfig"
] + (["django_gsuite_email"] if "django_gsuite_email" in EMAIL_BACKEND else [])

MIDDLEWARE = [
Expand Down
1 change: 1 addition & 0 deletions backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def wrapper(request):
path("api/document/", include("documents.urls")),
path("api/stats/", include("stats.urls")),
path("api/dissertations/", include("dissertations.urls")),
path("api/testimonials/", include("testimonials.urls")),
re_path(
r"^static/(?P<path>.*)$",
views.cached_serve,
Expand Down
1 change: 1 addition & 0 deletions backend/categories/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


class Category(models.Model):
id = models.AutoField(primary_key=True)
displayname = models.CharField(max_length=256)
slug = models.CharField(max_length=256, unique=True)
form = models.CharField(
Expand Down
1 change: 1 addition & 0 deletions backend/categories/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

urlpatterns = [
path("list/", views.list_categories, name="list"),
path("listwithid/", views.list_categories_with_id, name="listwithid"),
path("listwithmeta/", views.list_categories_with_meta, name="listwithmeta"),
path("listonlyadmin/", views.list_categories_only_admin, name="listonlyadmin"),
path("add/", views.add_category, name="add"),
Expand Down
16 changes: 16 additions & 0 deletions backend/categories/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
from util import response, func_cache


@response.request_get()
def list_categories_with_id(request):
categories = Category.objects.order_by("displayname").all()
res = [
{
"category_id": cat.id,
"displayname": cat.displayname,
"slug": cat.slug,
"euclid_codes": [euclidcode.code for euclidcode in cat.euclid_codes.all()]
}
for cat in categories
]
return response.success(value=res)

@response.request_get()
def list_categories(request):
categories = Category.objects.order_by("displayname").all()
Expand All @@ -27,6 +41,7 @@ def list_categories_with_meta(request):
categories = Category.objects.select_related("meta").order_by("displayname").all()
res = [
{
"category_id": cat.id,
"displayname": cat.displayname,
"slug": cat.slug,
"examcountpublic": cat.meta.examcount_public,
Expand Down Expand Up @@ -153,6 +168,7 @@ def list_exams(request, slug):

def get_category_data(request, cat):
res = {
"category_id": cat.id,
"displayname": cat.displayname,
"slug": cat.slug,
"admins": [],
Expand Down
1 change: 1 addition & 0 deletions backend/ediauth/auth_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def add_auth_to_request(request: HttpRequest):
NotificationType.NEW_COMMENT_TO_COMMENT,
NotificationType.NEW_ANSWER_TO_ANSWER,
NotificationType.NEW_COMMENT_TO_DOCUMENT,
NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS
]:
setting = NotificationSetting(user=user, type=type_.value)
setting.save()
Expand Down
1 change: 1 addition & 0 deletions backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class NotificationType(enum.Enum):
NEW_COMMENT_TO_COMMENT = 2
NEW_ANSWER_TO_ANSWER = 3
NEW_COMMENT_TO_DOCUMENT = 4
UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS = 5


class Notification(models.Model):
Expand Down
55 changes: 48 additions & 7 deletions backend/notifications/notification_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ def send_notification(
associated_data: Answer,
) -> None: ...

@overload
def send_notification(
sender: User,
receiver: User,
type_: Literal[
NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS
],
title: str,
message: str,
associated_data: str, #Update to Testimonial
) -> None: ...


@overload
def send_notification(
Expand All @@ -51,10 +63,11 @@ def send_notification(
type_: NotificationType,
title: str,
message: str,
associated_data: Union[Answer, Document],
associated_data: Union[Answer, Document, str],
):
if sender == receiver:
return

if is_notification_enabled(receiver, type_):
send_inapp_notification(
sender, receiver, type_, title, message, associated_data
Expand Down Expand Up @@ -97,7 +110,7 @@ def send_email_notification(
type_: NotificationType,
title: str,
message: str,
data: Union[Document, Answer],
data: Union[Document, Answer, str],
):
"""If the user has email notifications enabled, send an email notification.

Expand All @@ -114,16 +127,34 @@ def send_email_notification(
)
):
return

send_mail(
f"BetterInformatics: {title} / {data.display_name if isinstance(data, Document) else data.answer_section.exam.displayname}",

email_body = ""
if isinstance(data, Document):
email_body = f"BetterInformatics: {title} / {data.display_name}",
(
f"Hello {receiver.profile.display_username}!\n"
f"{message}\n\n"
f"View it in context here: {get_absolute_notification_url(data)}"
),
)
elif isinstance(data, str):
email_body = f"BetterInformatics: {title}",
(
f"Hello {receiver.profile.display_username}!\n"
f"{message}\n\n"
)
else:
email_body = f"BetterInformatics: {title} / {data.answer_section.exam.displayname}",
(
f"Hello {receiver.profile.display_username}!\n"
f"{message}\n\n"
f"View it in context here: {get_absolute_notification_url(data)}"
)

send_mail(
email_body,
f'"{sender.username} (via BetterInformatics)" <{settings.VERIF_CODE_FROM_EMAIL_ADDRESS}>',
[receiver.email],
from_email=[sender.email],
recipient_list=[receiver.email],
fail_silently=False,
)

Expand Down Expand Up @@ -200,3 +231,13 @@ def new_comment_to_document(document: Document, new_comment: DocumentComment):
"A new comment was added to your document.\n\n{}".format(new_comment.text),
document,
)

def update_to_testimonial_status(sender, receiver, title, message):
send_notification(
sender,
receiver,
NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS,
title,
message,
""
)
Empty file.
3 changes: 3 additions & 0 deletions backend/testimonials/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions backend/testimonials/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class TestimonialsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "testimonials"
Empty file.
34 changes: 34 additions & 0 deletions backend/testimonials/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.db import models
from django.db.models import Q, UniqueConstraint


class ApprovalStatus(models.IntegerChoices):
APPROVED = 0, "Approved"
PENDING = 1, "Pending"
REJECTED = 2, "Rejected"


class Testimonial(models.Model):
id = models.AutoField(primary_key=True)
author = models.ForeignKey("auth.User", on_delete=models.CASCADE, default="")
category = models.ForeignKey( # Link Testimonial to a Category
"categories.Category",
on_delete=models.CASCADE# Delete testimonials if category is deleted
)
testimonial = models.TextField()
year_taken = models.IntegerField()
approval_status = models.IntegerField(
choices=ApprovalStatus.choices,
default=ApprovalStatus.PENDING,
)

class Meta:
#Only one row with (author, course) where approval_status is APPROVED or PENDING can exist.
#Multiple rejected rows can exist for (author, course) combination.
constraints = [
UniqueConstraint(
fields=["author", "category"],
condition=Q(approval_status__in=[ApprovalStatus.APPROVED, ApprovalStatus.PENDING]),
name="unique_approved_or_pending_per_author_course",
),
]
3 changes: 3 additions & 0 deletions backend/testimonials/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
9 changes: 9 additions & 0 deletions backend/testimonials/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path
from testimonials import views
urlpatterns = [
path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"),
path("gettestimonial/", views.get_testimonial_metadata_by_code, name="get_testimonial"),
path('addtestimonial/', views.add_testimonial, name='add_testimonial'),
path('removetestimonial/', views.remove_testimonial, name='remove_testimonial'),
path('updatetestimonialapproval/', views.update_testimonial_approval_status, name="update_testimonial_approval_status")
]
140 changes: 140 additions & 0 deletions backend/testimonials/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from util import response
from ediauth import auth_check
from testimonials.models import Testimonial, ApprovalStatus
from categories.models import Category
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from datetime import timedelta
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from notifications.notification_util import update_to_testimonial_status
import ediauth.auth_check as auth_check

@response.request_get()
@auth_check.require_login
def testimonial_metadata(request):
testimonials = Testimonial.objects.all()
res = [
{
"author_id": testimonial.author.username,
"author_diplay_name": testimonial.author.profile.display_username,
"category_id": testimonial.category.id,
"euclid_codes": [euclidcode.code for euclidcode in testimonial.category.euclid_codes.all()],
"course_name": testimonial.category.displayname,
"testimonial": testimonial.testimonial,
"testimonial_id": testimonial.id,
"year_taken": testimonial.year_taken,
"approval_status": testimonial.approval_status,
}
for testimonial in testimonials
]
return response.success(value=res)

@response.request_get("category_id")
@auth_check.require_login
def get_testimonial_metadata_by_code(request):
category_id = request.POST.get('category_id')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint is unused. I can see it being useful though (maybe the category-page tab should be changed to use this endpoint instead), but there's a lot of duplicate with testimonial_metadata right above. I would combine them into one, providing an optional query parameter to filter the results.

Like:

/api/testimonials/listtestimonials <- for all
/api/testimonials/listtestimonials?category=my_slug_here <- for single category

try:
category_obj = Category.objects.get(id=category_id)
except Category.DoesNotExist:
return response.not_possible(f"The category with id {category_id} does not exist in the database.")
testimonials = Testimonial.objects.filter(category=category_obj)
res = [
{
"author_id": testimonial.author.username,
"author_diplay_name": testimonial.author.profile.display_username,
"category_id": testimonial.category.id,
"euclid_codes": testimonial.category.euclid_codes,
"course_name": testimonial.category.displayname,
"testimonial": testimonial.testimonial,
"testimonial_id": testimonial.id,
"year_taken": testimonial.year_taken,
"approval_status": testimonial.approval_status,
}
for testimonial in testimonials
]
return response.success(value=res)

@response.request_post("category_id", "year_taken", optional=True)
@auth_check.require_login
def add_testimonial(request):
author = request.user
category_id = request.POST.get('category_id') #course code instead of course name
year_taken = request.POST.get('year_taken')
testimonial = request.POST.get('testimonial')

if not author:
return response.not_possible("Missing argument: author")
if not year_taken:
return response.not_possible("Missing argument: year_taken")
if not testimonial:
return response.not_possible("Missing argument: testimonial")

testimonials = Testimonial.objects.all()
category_obj = Category.objects.get(id=category_id)

for t in testimonials:
if t.author == author and t.category == category_obj and (t.approval_status == ApprovalStatus.APPROVED):
return response.not_possible("You have written a testimonial for this course that has been approved.")
elif t.author == author and t.category == category_obj and (t.approval_status == ApprovalStatus.PENDING):
return response.not_possible("You have written a testimonial for this course that is currently pending approval.")

testimonial = Testimonial.objects.create(
author=author,
category=category_obj,
year_taken=year_taken,
approval_status= ApprovalStatus.PENDING,
testimonial=testimonial,
)

return response.success(value={"testimonial_id" : testimonial.id, "approved" : False})

@response.request_post("username", 'testimonial_id', optional=True)
@auth_check.require_login
def remove_testimonial(request):
username = request.POST.get('username')
testimonial_id = request.POST.get('testimonial_id')

testimonial = Testimonial.objects.filter(id=testimonial_id) #Since id is primary key, always returns 1 or none.

if not testimonial:
return response.not_possible("Testimonial not found for author: " + username + " with id " + testimonial_id)

if not (testimonial[0].author == request.user or auth_check.has_admin_rights(request)):
return response.not_possible("No permission to delete this.")

testimonial.delete()
return response.success(value="Deleted Testimonial " + str(testimonial))

@response.request_post("title", "message", optional=True)
@auth_check.require_login
def update_testimonial_approval_status(request):
sender = request.user
has_admin_rights = auth_check.has_admin_rights(request)
testimonial_author = request.POST.get('author')
receiver = get_object_or_404(User, username=testimonial_author)
testimonial_id = request.POST.get('testimonial_id')
title = request.POST.get('title')
message = request.POST.get('message')
approval_status = request.POST.get('approval_status')
course_name = request.POST.get('course_name')

testimonial = Testimonial.objects.filter(id=testimonial_id)

final_message = ""
if has_admin_rights:
testimonial.update(approval_status=approval_status)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to add checks (frontend & backend) so you can't approve or reject testimonials which already have a decision? Currently admins can approve the same testimonial many times in succession, each one sending a new notification. Maybe the buttons should be disabled and the backend should prevent it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will do that, I was just wondering if there has been a mistake in approving/disapproving a testimonial, an admin can go back and change their mistake? For example, they accidentally pressed approve on a testimonial, and they could go back and change it to disapproved?

Maybe instead, I should add an "are you sure" menu to approving/disapproving a testimonial so that if an admin makes a mistake, they can revert back the error by clicking "No" on the "Are you sure" menu. That way, the user does not get spammed with notifications too. I hope this makes sense.

if approval_status == str(ApprovalStatus.APPROVED.value):
final_message = f'Your Testimonial to {course_name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.'
if (sender != receiver):
update_to_testimonial_status(sender, receiver, title, final_message) #notification
return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".")
elif approval_status == str(ApprovalStatus.REJECTED.value):
final_message = f'Your Testimonial to {course_name}: \n"{testimonial[0].testimonial}" has not been accepted due to: {message}'
if (sender != receiver):
update_to_testimonial_status(sender, receiver, title, final_message) #notification
return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".")
else:
return response.not_possible("Cannot Update the Testimonial to approval_status: " + str(approval_status))
else:
return response.not_possible("No permission to approve/disapprove this testimonial.")
Loading
Loading