Skip to content

Commit 9203308

Browse files
committed
reviews: send rejection email
1 parent 07753bd commit 9203308

8 files changed

Lines changed: 162 additions & 31 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% extends "email_base.html" %}
2+
3+
{% block content %}
4+
<p>Hi {{username}},</p>
5+
6+
<p>Your review for the level {{level_name}} has been removed from TRCustoms.org.</p>
7+
8+
<p>Kindly find the reason provided by the Mod Team:</p>
9+
10+
<p>{{reason}}</p>
11+
12+
<p>Regards,<br />TRCustoms.org</p>
13+
{% endblock %}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{% extends "email_base.txt" %}
2+
3+
{% block content %}
4+
Hi {{username}},
5+
6+
Your review for the level {{level_name}} has been removed from TRCustoms.org.
7+
8+
Kindly find the reason provided by the Mod Team:
9+
10+
{{reason}}
11+
12+
Regards,
13+
TRCustoms.org
14+
{% endblock %}

backend/trcustoms/mails.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,21 @@ def send_review_update_mail(review: Review) -> None:
227227
)
228228

229229

230+
def send_review_removal_mail(review: Review, reason: str) -> None:
231+
if not review.author.email:
232+
return
233+
send_mail.delay(
234+
template_name="review_removal",
235+
subject=f"{PREFIX} Review removed",
236+
recipients=[review.author.email],
237+
context={
238+
"username": review.author.username,
239+
"level_name": review.level.name,
240+
"reason": reason,
241+
},
242+
)
243+
244+
230245
def send_rating_submission_mail(rating: Rating) -> None:
231246
link = f"{settings.HOST_SITE}/levels/{rating.level.id}"
232247
for user in get_level_authors(rating.level, include_uploader=False):

backend/trcustoms/reviews/serializers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from rest_framework import serializers
22

3+
from trcustoms.common.fields import CustomCharField
34
from trcustoms.levels.models import Level
45
from trcustoms.levels.serializers import LevelNestedSerializer
56
from trcustoms.mails import (
7+
send_review_removal_mail,
68
send_review_submission_mail,
79
send_review_update_mail,
810
)
@@ -96,3 +98,11 @@ def update(self, instance, validated_data):
9698
send_review_update_mail(review)
9799
update_awards.delay(review.author.pk)
98100
return review
101+
102+
103+
class ReviewDeletionSerializer(serializers.Serializer):
104+
reason = CustomCharField(collapse_whitespace=False, max_length=500)
105+
106+
def notify(self, instance: Review) -> None:
107+
send_review_removal_mail(instance, self.validated_data["reason"])
108+
instance.delete()

backend/trcustoms/reviews/tests/test_review_delete.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import pytest
2+
from django.core import mail
23
from rest_framework import status
34
from rest_framework.test import APIClient
45

6+
from trcustoms.audit_logs.consts import ChangeType
7+
from trcustoms.audit_logs.models import AuditLog
58
from trcustoms.levels.tests.factories import LevelFactory
69
from trcustoms.reviews.models import Review
710
from trcustoms.reviews.tests.factories import ReviewFactory
@@ -17,6 +20,7 @@ def test_review_deletion_updates_level_review_count(
1720

1821
response = staff_api_client.delete(
1922
f"/api/reviews/{review.id}/",
23+
data={"reason": "Off-topic content."},
2024
format="json",
2125
)
2226
level.refresh_from_db()
@@ -45,6 +49,7 @@ def test_review_deletion_updates_position(
4549

4650
staff_api_client.delete(
4751
f"/api/reviews/{review2.id}/",
52+
data={"reason": "Duplicate review."},
4853
format="json",
4954
)
5055
review1.refresh_from_db()
@@ -55,3 +60,64 @@ def test_review_deletion_updates_position(
5560
assert review1.last_updated == review1_last_updated
5661
assert review3.last_updated == review3_last_updated
5762
assert not Review.objects.filter(pk=review2.pk).exists()
63+
64+
65+
@pytest.mark.django_db
66+
def test_review_deletion_requires_reason(
67+
staff_api_client: APIClient,
68+
) -> None:
69+
review = ReviewFactory()
70+
71+
response = staff_api_client.delete(
72+
f"/api/reviews/{review.id}/",
73+
data={},
74+
format="json",
75+
)
76+
77+
assert (
78+
response.status_code == status.HTTP_400_BAD_REQUEST
79+
), response.content
80+
assert Review.objects.filter(pk=review.pk).exists()
81+
82+
83+
@pytest.mark.django_db
84+
def test_review_deletion_sends_reason_email(
85+
staff_api_client: APIClient,
86+
) -> None:
87+
review = ReviewFactory(
88+
author=UserFactory(email="reviewer@example.com", username="reviewer")
89+
)
90+
91+
response = staff_api_client.delete(
92+
f"/api/reviews/{review.id}/",
93+
data={"reason": "Contains harassment."},
94+
format="json",
95+
)
96+
97+
assert response.status_code == status.HTTP_204_NO_CONTENT, response.content
98+
assert len(mail.outbox) == 1
99+
assert mail.outbox[0].subject == "[TRCustoms] Review removed"
100+
assert mail.outbox[0].to == ["reviewer@example.com"]
101+
assert "Contains harassment." in mail.outbox[0].body
102+
assert review.level.name in mail.outbox[0].body
103+
104+
105+
@pytest.mark.django_db
106+
def test_review_deletion_creates_audit_log(
107+
staff_api_client: APIClient,
108+
) -> None:
109+
review = ReviewFactory()
110+
111+
response = staff_api_client.delete(
112+
f"/api/reviews/{review.id}/",
113+
data={"reason": "Spam."},
114+
format="json",
115+
)
116+
117+
audit_log = AuditLog.objects.first()
118+
119+
assert response.status_code == status.HTTP_204_NO_CONTENT, response.content
120+
assert audit_log
121+
assert audit_log.change_type == ChangeType.DELETE
122+
assert audit_log.object_id == str(review.id)
123+
assert audit_log.object_name == review.level.name

backend/trcustoms/reviews/views.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
from rest_framework import mixins, viewsets
1+
from rest_framework import mixins, status, viewsets
22
from rest_framework.generics import get_object_or_404
33
from rest_framework.permissions import AllowAny, IsAuthenticated
4+
from rest_framework.response import Response
45

6+
from trcustoms.audit_logs.utils import (
7+
clear_audit_log_action_flags,
8+
track_model_deletion,
9+
)
510
from trcustoms.mixins import (
611
AuditLogModelWatcherMixin,
712
MultiSerializerMixin,
@@ -14,6 +19,7 @@
1419
)
1520
from trcustoms.reviews.models import Review
1621
from trcustoms.reviews.serializers import (
22+
ReviewDeletionSerializer,
1723
ReviewDetailsSerializer,
1824
ReviewListingSerializer,
1925
)
@@ -75,6 +81,7 @@ class ReviewViewSet(
7581
"update": ReviewDetailsSerializer,
7682
"partial_update": ReviewDetailsSerializer,
7783
"create": ReviewDetailsSerializer,
84+
"destroy": ReviewDeletionSerializer,
7885
}
7986

8087
def get_object(self):
@@ -94,3 +101,17 @@ def get_queryset(self):
94101
queryset = queryset.filter(level_id=level_id)
95102

96103
return queryset
104+
105+
def destroy(self, request, *args, **kwargs):
106+
instance = self.get_object()
107+
review_id = instance.pk
108+
audit_log_instance = self.get_queryset().get(pk=review_id)
109+
serializer = self.get_serializer(data=request.data)
110+
serializer.is_valid(raise_exception=True)
111+
serializer.notify(instance)
112+
clear_audit_log_action_flags(obj=audit_log_instance)
113+
track_model_deletion(
114+
audit_log_instance, request=self.request, notify=True
115+
)
116+
Review.objects.filter(pk=review_id).delete()
117+
return Response(status=status.HTTP_204_NO_CONTENT)

frontend/src/components/buttons/ReviewDeleteButton/index.tsx

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { useState } from "react";
21
import { useQueryClient } from "react-query";
3-
import { Button } from "src/components/common/Button";
2+
import { PromptButton } from "src/components/buttons/PromptButton";
43
import { IconTrash } from "src/components/icons";
5-
import { ConfirmModal } from "src/components/modals/ConfirmModal";
64
import { ReviewService } from "src/services/ReviewService";
75
import type { ReviewListing } from "src/services/ReviewService";
86
import { resetQueries } from "src/utils/misc";
9-
import { showAlertOnError } from "src/utils/misc";
107

118
interface ReviewDeleteButtonProps {
129
review: ReviewListing;
@@ -17,35 +14,24 @@ const ReviewDeleteButton = ({
1714
review,
1815
onComplete,
1916
}: ReviewDeleteButtonProps) => {
20-
const [isModalActive, setIsModalActive] = useState(false);
2117
const queryClient = useQueryClient();
2218

23-
const handleButtonClick = () => {
24-
setIsModalActive(true);
25-
};
26-
27-
const handleModalConfirm = () => {
28-
showAlertOnError(async () => {
29-
await ReviewService.delete(review.id);
30-
onComplete?.();
31-
resetQueries(queryClient, ["reviews"]);
32-
});
19+
const handleConfirm = async (reason: string) => {
20+
await ReviewService.delete(review.id, { reason });
21+
onComplete?.();
22+
resetQueries(queryClient, ["reviews"]);
3323
};
3424

3525
return (
36-
<>
37-
<ConfirmModal
38-
isActive={isModalActive}
39-
onIsActiveChange={setIsModalActive}
40-
onConfirm={handleModalConfirm}
41-
>
42-
Are you sure you want to delete this review?
43-
</ConfirmModal>
44-
45-
<Button icon={<IconTrash />} onClick={handleButtonClick}>
46-
Delete review
47-
</Button>
48-
</>
26+
<PromptButton
27+
text={<p>Please provide the reason for deleting this review.</p>}
28+
promptLabel="Reason"
29+
buttonLabel="Delete review"
30+
buttonTooltip="Deletes this review and emails the author the reason."
31+
icon={<IconTrash />}
32+
big={true}
33+
onConfirm={handleConfirm}
34+
/>
4935
);
5036
};
5137

frontend/src/services/ReviewService.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ interface ReviewBaseChangePayload {
4141

4242
interface ReviewUpdatePayload extends ReviewBaseChangePayload {}
4343
interface ReviewCreatePayload extends ReviewBaseChangePayload {}
44+
interface ReviewDeletePayload {
45+
reason: string;
46+
}
4447

4548
const searchReviews = async (
4649
searchQuery: ReviewSearchQuery,
@@ -104,8 +107,11 @@ const create = async (payload: ReviewCreatePayload): Promise<ReviewDetails> => {
104107
return response.data;
105108
};
106109

107-
const deleteReview = async (reviewId: number): Promise<void> => {
108-
await api.delete(`${API_URL}/reviews/${reviewId}/`);
110+
const deleteReview = async (
111+
reviewId: number,
112+
payload: ReviewDeletePayload,
113+
): Promise<void> => {
114+
await api.delete(`${API_URL}/reviews/${reviewId}/`, { data: payload });
109115
};
110116

111117
const ReviewService = {

0 commit comments

Comments
 (0)