Repository: opensupplyhub/open-supply-hub
Pull request #538: [OSDEV-1782] Create a pop-up window to enter a text message for the rejection action of contribution.
Author: login: mazursasha1990
Created: 2025-02-26T20:48:16Z | Merged: 2025-03-07T08:08:09Z

[OSDEV-1782](https://opensupplyhub.atlassian.net/browse/OSDEV-1782) - [SLC] Moderation Queue: Record rejection options & enable pop-up window to enter text.

In this PR, the following changes were made:

- New fields action_reason_text_cleaned and action_reason_text_raw were added to the api_moderationevent table to store text messages received when a moderator takes an action on a moderation event.
- Additional validation was added for the fields action_reason_text_cleaned and action_reason_text_raw when using the PATCH api/v1/moderation-events/{moderation_id} endpoint. These fields are required in the request body when the status field is set to 'REJECTED'. The minimum length for the values of these fields is 30 characters.
- A confirmation dialog window was added that appears when a user tries to reject a moderation event. The dialog includes a WYSIWYG text editor where entering a message of at least 30 characters is required to confirm the rejection. If a user does not enter the required number of characters, the 'Reject' button is disabled, and a tooltip with a clear message appears when the mouse hovers over it."
- Was added sanitization on the server side by using the `Django-Bleach` library for the HTML content that is stored in the `action_reason_text_raw` field. The bleach filter was applied to the `action_reason_text_raw` value in the `slc_contribution_rejected_body.html` template.

![Screenshot from 2025-03-04 22-21-09](https://github.com/user-attachments/assets/0118605d-b99c-43e3-845a-e29b3af12c19)
![Screenshot from 2025-03-04 19-51-02](https://github.com/user-attachments/assets/fc9f8eb3-5393-4c36-9693-d59369d6e6ac)



[OSDEV-1782]: https://opensupplyhub.atlassian.net/browse/OSDEV-1782?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md
index 96603e799..6ae1b4a07 100644
--- a/doc/release/RELEASE-NOTES.md
+++ b/doc/release/RELEASE-NOTES.md
@@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
 
 #### Migrations:
-* *Describe migrations here.*
+* 0167_add_moderationevent_action_reason_text_fields.py - This migration adds new fields `action_reason_text_cleaned` and  `action_reason_text_raw` to the existing table `api_moderationevent`.
 
 #### Schema changes
-* *Describe schema changes here.*
+* [OSDEV-1782](https://opensupplyhub.atlassian.net/browse/OSDEV-1782) - Added new fields `action_reason_text_cleaned` and `action_reason_text_raw` to the `api_moderationevent` table to store text messages received when a moderator takes an action on a moderation event.
 
 ### Code/API changes
-* *Describe code/API changes here.*
+* [OSDEV-1782](https://opensupplyhub.atlassian.net/browse/OSDEV-1782) - Added additional validation for the fields `action_reason_text_cleaned` and `action_reason_text_raw` when using the `PATCH api/v1/moderation-events/{moderation_id}` endpoint. These fields are required in the request body when the status field is set to 'REJECTED'. The minimum length for the values of these fields is 30 characters.
+Also was added sanitization on the server side by using the `Django-Bleach` library for the HTML content that is stored in the `action_reason_text_raw` field. The bleach filter was applied to the `action_reason_text_raw` value in the `slc_contribution_rejected_body.html` template.
 
 ### Architecture/Environment changes
 * [OSDEV-899](https://opensupplyhub.atlassian.net/browse/OSDEV-899) - Splitted the Django container into two components: FE (React) and BE (Django). Requests to the frontend (React) will be processed by the CDN (CloudFront), while requests to the API will be redirected to the Django container. This approach will allow for more efficient use of ECS cluster computing resources and improve frontend performance.
@@ -50,9 +51,6 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 
   All other traffic will be redirected to the React application.
 
-### What's new
-* [OSDEV-1814](https://opensupplyhub.atlassian.net/browse/OSDEV-1814) - Added toggle switch button for production location info page to render additional data if necessary. If toggle switch button is inactive (default behavior), additional data won't be send to the server along with name, address and country.
-
 ### Bugfix
 * [OSDEV-1806](https://opensupplyhub.atlassian.net/browse/OSDEV-1806) - Refactored the Parent Company field validation. The field is now validated as a regular character field.
 * [OSDEV-1787](https://opensupplyhub.atlassian.net/browse/OSDEV-1787) - The tooltip messages for the Claim button have been removed for all statuses of moderation events on the `Contribution Record` page and changed according to the design on `Thanks for adding data for this production location` pop-up.
@@ -60,6 +58,10 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * [OSDEV-1795](https://opensupplyhub.atlassian.net/browse/OSDEV-1795) - Resolved database connection issue after PostgreSQL 16.3 upgrade by upgrading pg8000 module version.
 * [OSDEV-1803](https://opensupplyhub.atlassian.net/browse/OSDEV-1803) - Updated text from `Facility Type` to `Location Type` and `Facility Name` to `Location Name` on the SLC `Thank You for Your Submission` page. 
 
+### What's new
+* [OSDEV-1814](https://opensupplyhub.atlassian.net/browse/OSDEV-1814) - Added toggle switch button for production location info page to render additional data if necessary. If toggle switch button is inactive (default behavior), additional data won't be send to the server along with name, address and country.
+* [OSDEV-1782](https://opensupplyhub.atlassian.net/browse/OSDEV-1782) - Added a confirmation dialog window that appears when a user tries to reject a moderation event. The dialog includes a WYSIWYG text editor where entering a message of at least 30 characters is required to confirm the rejection. If a user does not enter the required number of characters, the 'Reject' button is disabled, and a tooltip with a clear message appears when the mouse hovers over it.
+
 ### Release instructions:
 * Ensure that the following commands are included in the `post_deployment` command:
     * `migrate`
diff --git a/src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py b/src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py
new file mode 100644
index 000000000..aaf305ae9
--- /dev/null
+++ b/src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.17 on 2025-02-27 11:10
+
+from django.db import migrations, models
+import django_bleach.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0166_add_moderationevent_action_perform_by'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='moderationevent',
+            name='action_reason_text_cleaned',
+            field=models.TextField(
+                blank=True,
+                help_text='Cleaned version of the action reason text.',
+            ),
+        ),
+        migrations.AddField(
+            model_name='moderationevent',
+            name='action_reason_text_raw',
+            field=django_bleach.models.BleachField(
+                blank=True,
+                help_text='Raw version of the action reason text.'
+            ),
+        ),
+    ]
diff --git a/src/django/api/models/moderation_event.py b/src/django/api/models/moderation_event.py
index 0c9da2fbf..565c10709 100644
--- a/src/django/api/models/moderation_event.py
+++ b/src/django/api/models/moderation_event.py
@@ -1,5 +1,6 @@
 import uuid
 from django.db import models
+from django_bleach.models import BleachField
 from api.models.contributor.contributor import Contributor
 from api.models.facility.facility import Facility
 from api.models.facility.facility_claim import FacilityClaim
@@ -147,6 +148,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
+        blank=True,
+        help_text='Cleaned version of the action reason text.'
+    )
+
+    action_reason_text_raw = BleachField(
+        blank=True,
+        help_text='Raw version of the action reason text.'
+    )
+
     def __str__(self):
         return (
             f'ModerationEvent entry {self.uuid} '
diff --git a/src/django/api/serializers/v1/moderation_event_update_serializer.py b/src/django/api/serializers/v1/moderation_event_update_serializer.py
index 74acb17fb..37964d6d0 100644
--- a/src/django/api/serializers/v1/moderation_event_update_serializer.py
+++ b/src/django/api/serializers/v1/moderation_event_update_serializer.py
@@ -11,6 +11,10 @@
 
 
 class ModerationEventUpdateSerializer(ModelSerializer):
+    MIN_REASON_TEXT_LENGTH = 30
+
+    action_reason_text_cleaned = CharField(write_only=True, required=False)
+    action_reason_text_raw = CharField(write_only=True, required=False)
 
     contributor_id = IntegerField(source='contributor.id', read_only=True)
     contributor_name = CharField(source='contributor.name', read_only=True)
@@ -32,7 +36,9 @@ class Meta:
             'source',
             'status',
             'status_change_date',
-            'claim_id'
+            'claim_id',
+            'action_reason_text_cleaned',
+            'action_reason_text_raw',
         ]
 
     def __init__(
@@ -78,12 +84,61 @@ def __validate_status(self, value):
             })
         return value
 
+    def validate(self, data):
+        status = data.get('status')
+
+        if status != ModerationEvent.Status.REJECTED:
+            return data
+
+        cleaned = data.get('action_reason_text_cleaned')
+        raw = data.get('action_reason_text_raw')
+        errors = []
+
+        if not cleaned:
+            errors.append({
+                "field": "action_reason_text_cleaned",
+                "detail": (
+                    "This field is required when rejecting a moderation "
+                    "event."
+                )
+            })
+        elif len(cleaned) < self.MIN_REASON_TEXT_LENGTH:
+            errors.append({
+                "field": "action_reason_text_cleaned",
+                "detail": "This field must be at least 30 characters."
+            })
+
+        if not raw:
+            errors.append({
+                "field": "action_reason_text_raw",
+                "detail": (
+                    "This field is required when rejecting a moderation "
+                    "event."
+                )
+            })
+        elif len(raw) < self.MIN_REASON_TEXT_LENGTH:
+            errors.append({
+                "field": "action_reason_text_raw",
+                "detail": "This field must be at least 30 characters."
+            })
+
+        if errors:
+            raise ValidationError(errors)
+
+        return data
+
     def update(self, instance, validated_data):
         if 'status' in validated_data:
             value = validated_data['status']
             if value == ModerationEvent.Status.REJECTED:
                 instance.action_type = ModerationEvent.ActionType.REJECTED
                 instance.action_perform_by = self.__moderator
+                instance.action_reason_text_cleaned = validated_data[
+                    'action_reason_text_cleaned'
+                ]
+                instance.action_reason_text_raw = validated_data[
+                    'action_reason_text_raw'
+                ]
 
             instance.status = value
             instance.status_change_date = now()
diff --git a/src/django/api/templates/mail/slc_contribution_rejected_body.html b/src/django/api/templates/mail/slc_contribution_rejected_body.html
index 3fea227d4..259dbb9a6 100644
--- a/src/django/api/templates/mail/slc_contribution_rejected_body.html
+++ b/src/django/api/templates/mail/slc_contribution_rejected_body.html
@@ -1,3 +1,4 @@
+{% load bleach_tags %}
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -21,9 +22,7 @@
         <p>
           <b>Review the feedback below:</b>
         </p>
-        <ul>
-          <li>{{ action_reason_text_raw }}</li>
-        </ul>
+        {{ action_reason_text_raw | bleach }}
       </li>
       <li>
         <p>
diff --git a/src/django/api/tests/test_moderation_events_update.py b/src/django/api/tests/test_moderation_events_update.py
index 70d7f5a3c..129e3ae75 100644
--- a/src/django/api/tests/test_moderation_events_update.py
+++ b/src/django/api/tests/test_moderation_events_update.py
@@ -1,13 +1,16 @@
 import json
+
+from django.db.models.signals import post_save
 from django.test import override_settings
+from django.utils.timezone import now
 from django.core import mail
+from rest_framework.test import APITestCase
 from api.models import (
     ModerationEvent,
     User,
-    Contributor
+    Contributor,
 )
-from django.utils.timezone import now
-from rest_framework.test import APITestCase
+from api.signals import moderation_event_update_handler_for_opensearch
 
 
 @override_settings(DEBUG=True)
@@ -15,6 +18,11 @@ class ModerationEventsUpdateTest(APITestCase):
     def setUp(self):
         super().setUp()
 
+        post_save.disconnect(
+            moderation_event_update_handler_for_opensearch,
+            ModerationEvent
+        )
+
         self.email = "test@example.com"
         self.password = "example123"
         self.user = User.objects.create(email=self.email)
@@ -100,7 +108,7 @@ def test_moderation_event_invalid_status(self):
 
         self.assertEqual(400, response.status_code)
 
-    def test_moderation_event_status_changed(self):
+    def test_moderation_event_rejected_with_valid_reason_text(self):
         self.client.login(
             email=self.superemail,
             password=self.superpassword
@@ -108,7 +116,13 @@ def test_moderation_event_status_changed(self):
         response = self.client.patch(
             "/api/v1/moderation-events/{}/"
             .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"),
-            data=json.dumps({"status": "REJECTED"}),
+            data=json.dumps({
+                "status": "REJECTED",
+                "action_reason_text_cleaned": (
+                    "Cleaned reason text with 30 characters"
+                ),
+                "action_reason_text_raw": "Raw reason text with 30 characters"
+            }),
             content_type="application/json"
         )
 
@@ -127,3 +141,81 @@ def test_moderation_event_status_changed(self):
             self.superuser.id
         )
         self.assertIsNotNone(self.moderation_event.status_change_date)
+        self.assertEqual(
+            self.moderation_event.action_reason_text_cleaned,
+            "Cleaned reason text with 30 characters"
+        )
+        self.assertEqual(
+            self.moderation_event.action_reason_text_raw,
+            "Raw reason text with 30 characters"
+        )
+
+    def test_moderation_event_rejected_with_invalid_reason_text(self):
+        self.client.login(
+            email=self.superemail,
+            password=self.superpassword
+        )
+        response = self.client.patch(
+            "/api/v1/moderation-events/{}/"
+            .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"),
+            data=json.dumps({
+                "status": "REJECTED",
+                "action_reason_text_cleaned": "Too short cleaned text",
+                "action_reason_text_raw": "Too short raw text"
+            }),
+            content_type="application/json"
+        )
+
+        self.assertEqual(400, response.status_code)
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "The request body is invalid.",
+                "errors": [
+                    {
+                        "field": "action_reason_text_cleaned",
+                        "detail": "This field must be at least 30 characters."
+                    },
+                    {
+                        "field": "action_reason_text_raw",
+                        "detail": "This field must be at least 30 characters."
+                    }
+                ]
+            }
+        )
+        self.moderation_event.refresh_from_db()
+        self.assertEqual(self.moderation_event.status, "PENDING")
+
+    def test_moderation_event_rejected_without_reason(self):
+        self.client.login(
+            email=self.superemail,
+            password=self.superpassword
+        )
+        response = self.client.patch(
+            "/api/v1/moderation-events/{}/"
+            .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"),
+            data=json.dumps({"status": "REJECTED"}),
+            content_type="application/json"
+        )
+
+        self.assertEqual(400, response.status_code)
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "The request body is invalid.",
+                "errors": [
+                    {
+                        "field": "action_reason_text_cleaned",
+                        "detail": "This field is required when rejecting a "
+                                  "moderation event."
+                    },
+                    {
+                        "field": "action_reason_text_raw",
+                        "detail": "This field is required when rejecting a "
+                                  "moderation event."
+                    }
+                ]
+            }
+        )
+        self.moderation_event.refresh_from_db()
+        self.assertEqual(self.moderation_event.status, "PENDING")
diff --git a/src/django/api/views/v1/moderation_events.py b/src/django/api/views/v1/moderation_events.py
index e75687b31..fcf82a132 100644
--- a/src/django/api/views/v1/moderation_events.py
+++ b/src/django/api/views/v1/moderation_events.py
@@ -126,12 +126,15 @@ def partial_update(self, request, pk=None):
             user=request.user,
             partial=True
         )
-
         if not serializer.is_valid():
+            errors = serializer.errors.get(
+                "non_field_errors",
+                [serializer.errors]
+            )
             raise ValidationError(
                 {
                     "detail": APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR,
-                    "errors": [serializer.errors],
+                    "errors": errors,
                 }
             )
 
diff --git a/src/django/oar/settings.py b/src/django/oar/settings.py
index 8832f7862..0f37612fb 100644
--- a/src/django/oar/settings.py
+++ b/src/django/oar/settings.py
@@ -131,6 +131,7 @@
     'api',
     'web',
     'ecsmanage',
+    'django_bleach',
 ]
 
 # For allauth
@@ -563,3 +564,19 @@
 
 KAFKA_BOOTSTRAP_SERVERS = os.getenv('KAFKA_BOOTSTRAP_SERVERS', '') # Kafka servers to connect
 KAFKA_TOPIC_DEDUPE_BASIC_NAME = os.getenv('KAFKA_TOPIC_DEDUPE_BASIC_NAME', '') # Kafka Dedupe Hub Topic
+
+# Django Bleach settings
+# https://django-bleach.readthedocs.io/en/latest/
+BLEACH_ALLOWED_TAGS = [
+    'p', 'br', 'em', 'strong', 'ins', 'del', 'code', 'sup', 'sub',
+    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre',
+    'ul', 'ol', 'li', 'a',
+]
+
+BLEACH_ALLOWED_ATTRIBUTES = {
+    'a': ['href', 'target', 'title'],
+}
+
+BLEACH_STRIP_TAGS = True
+
+BLEACH_STRIP_COMMENTS = True
diff --git a/src/django/requirements.txt b/src/django/requirements.txt
index 4fb2901ac..42f342135 100644
--- a/src/django/requirements.txt
+++ b/src/django/requirements.txt
@@ -43,3 +43,4 @@ gevent==22.8.0
 google-api-python-client==2.156.0
 google-auth-httplib2==0.2.0
 google-auth-oauthlib==1.2.1
+django-bleach==2.0.0
diff --git a/src/react/package.json b/src/react/package.json
index d24a97cff..bbac05629 100644
--- a/src/react/package.json
+++ b/src/react/package.json
@@ -14,6 +14,8 @@
         "@turf/turf": "5.1.6",
         "axios": "0.28.0",
         "core-js": "3.9.1",
+        "draft-js": "^0.11.7",
+        "draftjs-to-html": "^0.9.1",
         "file-saver": "2.0.0",
         "http-proxy-middleware": "0.19.1",
         "immutability-helper": "2.9.0",
@@ -28,6 +30,7 @@
         "react-beautiful-dnd": "^13.1.0",
         "react-copy-to-clipboard": "5.0.1",
         "react-dom": "16.13.1",
+        "react-draft-wysiwyg": "^1.15.0",
         "react-infinite": "0.13.0",
         "react-infinite-any-height": "2.3.0",
         "react-leaflet": "2.4.0",
diff --git a/src/react/src/__tests__/components/DashboardContributionRecord.test.js b/src/react/src/__tests__/components/DashboardContributionRecord.test.js
index a10569ab0..dc91d4a5f 100644
--- a/src/react/src/__tests__/components/DashboardContributionRecord.test.js
+++ b/src/react/src/__tests__/components/DashboardContributionRecord.test.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { BrowserRouter as Router, useHistory } from 'react-router-dom';
-import { waitFor } from '@testing-library/react';
+import { waitFor, fireEvent } from '@testing-library/react';
 import DashboardContributionRecord from '../../components/Dashboard/DashboardContributionRecord';
 import renderWithProviders from '../../util/testUtils/renderWithProviders';
 import {
@@ -31,6 +31,19 @@ jest.mock('../../actions/dashboardContributionRecord', () => {
   };
 });
 
+jest.mock('../../components/Dashboard/RejectModerationEventDialog', () => ({
+  __esModule: true,
+  default: ({ isOpenDialog, closeDialog }) => (
+    <div>
+      {isOpenDialog && (
+        <button type="button" onClick={closeDialog}>
+          Close Dialog
+        </button>
+      )}
+    </div>
+  ),
+}));
+
 const mockHistoryPush = jest.fn();
 
 describe('DashboardContributionRecord component', () => {
@@ -284,4 +297,25 @@ describe('DashboardContributionRecord component', () => {
     const res = reduxStore.getState().dashboardContributionRecord.potentialMatches.matches
     expect(res.length).toBe(3);
   });
+
+  test('opens the RejectModerationEventDialog when the Reject Contribution button is clicked', () => {
+    const { getByRole } = renderComponent();
+
+    const rejectButton = getByRole('button', { name: /Reject Contribution/i });
+    fireEvent.click(rejectButton);
+
+    expect(getByRole('button', { name: /Close Dialog/i })).toBeInTheDocument();
+  });
+
+  test('closes the RejectModerationEventDialog when the close button is clicked', () => {
+    const { getByRole, queryByText } = renderComponent();
+
+    const rejectButton = getByRole('button', { name: /Reject Contribution/i });
+    fireEvent.click(rejectButton);
+
+    const closeButton = getByRole('button', { name: /Close Dialog/i });
+    fireEvent.click(closeButton);
+
+    expect(queryByText('Close Dialog')).not.toBeInTheDocument();
+  });
 });
diff --git a/src/react/src/__tests__/components/RejectModerationEventDialog.test.js b/src/react/src/__tests__/components/RejectModerationEventDialog.test.js
new file mode 100644
index 000000000..6eb5e9325
--- /dev/null
+++ b/src/react/src/__tests__/components/RejectModerationEventDialog.test.js
@@ -0,0 +1,144 @@
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+
+import RejectModerationEventDialog from '../../components/Dashboard/RejectModerationEventDialog';
+import { MODERATION_STATUSES_ENUM } from '../../util/constants';
+import renderWithProviders from '../../util/testUtils/renderWithProviders';
+
+const mockUpdateModerationEvent = jest.fn();
+const mockCloseDialog = jest.fn();
+
+jest.mock("react-draft-wysiwyg", () => {
+    // eslint-disable-next-line global-require
+    const { EditorState, ContentState } = require("draft-js");
+    return {
+        Editor: ({ editorState, onEditorStateChange }) => (
+            <textarea
+                data-testid="fake-editor"
+                value={editorState.getCurrentContent().getPlainText()}
+                onChange={(e) => {
+                    const newState = EditorState.createWithContent(
+                        ContentState.createFromText(e.target.value),
+                    );
+                    onEditorStateChange(newState);
+                }}
+            />
+        ),
+    };
+});
+
+jest.mock('@material-ui/core/Tooltip', () => ({ children, title, open, onOpen, onClose }) => (
+    <div
+        data-testid="tooltip-wrapper"
+        onMouseOver={onOpen}
+        onFocus={onOpen}
+        onMouseOut={onClose}
+        onBlur={onClose}    
+    >
+        {children}
+        {open && <div data-testid="tooltip">{title}</div>}
+    </div>
+));
+  
+  
+describe('RejectModerationEventDialog component', () => {
+    const defaultProps = {
+        updateModerationEvent: mockUpdateModerationEvent,
+        isOpenDialog: true,
+        closeDialog: mockCloseDialog,
+    };
+
+    afterEach(() => {
+        jest.clearAllMocks();
+    });
+
+    test('renders the dialog with the correct title and buttons', () => {
+        const { getByText, getByRole } = renderWithProviders(
+            <RejectModerationEventDialog {...defaultProps} />,
+        );
+
+        expect(getByText("Reject this Moderation Event?")).toBeInTheDocument();
+        expect(getByText('Keep the instructions brief and action orientated. (Suggested opening, "Just a few fixes are needed.")')).toBeInTheDocument();
+        expect(getByRole('button', {name: /Cancel/i})).toBeInTheDocument();
+        expect(getByRole('button', {name: /Reject/i})).toBeInTheDocument();
+    });
+
+    test('calls closeDialog when "Cancel" button is clicked', () => {
+        const { getByRole } = renderWithProviders(<RejectModerationEventDialog {...defaultProps} />);
+        const cancelButton = getByRole('button', {name: /Cancel/i});
+        
+        fireEvent.click(cancelButton);
+        expect(mockCloseDialog).toHaveBeenCalledTimes(1);
+    });
+
+    test('reject button is disabled if editor text is less than 30 characters', () => {
+        const { getByRole } = renderWithProviders(
+            <RejectModerationEventDialog {...defaultProps} />
+        );
+        const rejectButton = getByRole('button', {name: /Reject/i});
+        
+        expect(rejectButton).toBeDisabled();
+      });
+      
+    test('reject button is enabled when editor text is at least 30 characters and calls updateModerationEvent and closeDialog on click', async () => {
+        const { getByRole, getByTestId } = renderWithProviders(
+            <RejectModerationEventDialog {...defaultProps} />
+        );
+        const rejectButton = getByRole('button', {name: /Reject/i});
+
+        expect(rejectButton).toBeDisabled();
+
+        const fakeEditor = getByTestId('fake-editor');
+        const validText = 'a'.repeat(30);
+
+        fireEvent.change(fakeEditor, { target: { value: validText } });
+
+        expect(rejectButton).toBeEnabled();
+        
+        fireEvent.click(rejectButton);
+        
+        expect(mockUpdateModerationEvent).toHaveBeenCalledTimes(1);
+        expect(mockUpdateModerationEvent).toHaveBeenCalledWith(
+            MODERATION_STATUSES_ENUM.REJECTED,
+            validText,
+            `<p>${validText}</p>\n`,
+        );
+        expect(mockCloseDialog).toHaveBeenCalledTimes(1);
+    });
+
+    test('displays tooltip on hover when reject button is disabled', () => {
+        const { getByRole, queryByText } = renderWithProviders(
+            <RejectModerationEventDialog {...defaultProps} />
+        );
+        const tooltipText = 'Please provide a message with at least 30 characters.';
+
+        expect(queryByText(tooltipText)).not.toBeInTheDocument();
+
+        const rejectButton = getByRole('button', { name: /Reject/i });
+        expect(rejectButton).toBeDisabled();
+
+        fireEvent.mouseOver(rejectButton);
+
+        expect(queryByText(tooltipText)).toBeInTheDocument();
+
+        fireEvent.mouseOut(rejectButton);
+
+        expect(queryByText(tooltipText)).not.toBeInTheDocument()
+    });
+
+    test('does not display tooltip when reject button is enabled', () => {
+        const { getByRole, queryByText, getByTestId } = renderWithProviders(
+            <RejectModerationEventDialog {...defaultProps} />
+        );
+        const rejectButton = getByRole('button', { name: /Reject/i });
+        const fakeEditor = getByTestId('fake-editor');
+        const validText = 'a'.repeat(30);
+
+        fireEvent.change(fakeEditor, { target: { value: validText } });
+
+        expect(rejectButton).toBeEnabled();
+        
+        fireEvent.mouseOver(rejectButton);
+        expect(queryByText('Please provide a message with at least 30 characters.')).not.toBeInTheDocument();
+    });
+});
diff --git a/src/react/src/actions/dashboardContributionRecord.js b/src/react/src/actions/dashboardContributionRecord.js
index 138838941..c5cde12ba 100644
--- a/src/react/src/actions/dashboardContributionRecord.js
+++ b/src/react/src/actions/dashboardContributionRecord.js
@@ -130,7 +130,12 @@ export function fetchPotentialMatches(data) {
     };
 }
 
-export function updateSingleModerationEvent(moderationID, status) {
+export function updateSingleModerationEvent(
+    moderationID,
+    status,
+    textCleaned,
+    textRaw,
+) {
     return dispatch => {
         if (!moderationID) {
             return null;
@@ -138,7 +143,11 @@ export function updateSingleModerationEvent(moderationID, status) {
         dispatch(startUpdateSingleModerationEvent());
 
         return apiRequest
-            .patch(makeModerationEventRecordURL(moderationID), { status })
+            .patch(makeModerationEventRecordURL(moderationID), {
+                status,
+                action_reason_text_cleaned: textCleaned,
+                action_reason_text_raw: textRaw,
+            })
             .then(({ data }) => {
                 dispatch(completeUpdateSingleModerationEvent(data));
             })
diff --git a/src/react/src/components/Dashboard/DashboardContributionRecord.jsx b/src/react/src/components/Dashboard/DashboardContributionRecord.jsx
index a328e7a23..84b4e2438 100644
--- a/src/react/src/components/Dashboard/DashboardContributionRecord.jsx
+++ b/src/react/src/components/Dashboard/DashboardContributionRecord.jsx
@@ -19,6 +19,7 @@ import AppBar from '@material-ui/core/AppBar';
 import Toolbar from '@material-ui/core/Toolbar';
 import Typography from '@material-ui/core/Typography';
 
+import RejectModerationEventDialog from './RejectModerationEventDialog';
 import { makeDashboardContributionRecordStyles } from '../../util/styles';
 import {
     moderationEventsListItemPropType,
@@ -86,6 +87,10 @@ const DashboardContributionRecord = ({
 }) => {
     const prevSingleModerationEventItemRef = useRef();
     const [showBackdrop, setShowBackdrop] = useState(false);
+    const [
+        rejectModerationEventDialogIsOpen,
+        setRejectModerationEventDialogIsOpen,
+    ] = useState(false);
     const {
         productionLocationName,
         countryCode,
@@ -161,6 +166,14 @@ const DashboardContributionRecord = ({
         }
     }, [productionLocationName, countryCode, productionLocationAddress, osId]);
 
+    const handleRejectContribution = () => {
+        setRejectModerationEventDialogIsOpen(true);
+    };
+
+    const handleRejectModerationEventDialogClose = () => {
+        setRejectModerationEventDialogIsOpen(false);
+    };
+
     if (fetchModerationEventError) {
         return (
             <Typography variant="body2" className={classes.errorStyle}>
@@ -387,11 +400,7 @@ const DashboardContributionRecord = ({
                 <Button
                     color="secondary"
                     variant="contained"
-                    onClick={() => {
-                        updateModerationEvent(
-                            MODERATION_STATUSES_ENUM.REJECTED,
-                        );
-                    }}
+                    onClick={handleRejectContribution}
                     className={classes.buttonStyles}
                     disabled={isDisabled || moderationEventFetching}
                 >
@@ -419,6 +428,11 @@ const DashboardContributionRecord = ({
                     )}
                 </Grid>
             </Grid>
+            <RejectModerationEventDialog
+                updateModerationEvent={updateModerationEvent}
+                isOpenDialog={rejectModerationEventDialogIsOpen}
+                closeDialog={handleRejectModerationEventDialogClose}
+            />
         </>
     );
 };
@@ -477,8 +491,15 @@ const mapDispatchToProps = (
     push,
     fetchModerationEvent: () =>
         dispatch(fetchSingleModerationEvent(moderationID)),
-    updateModerationEvent: status =>
-        dispatch(updateSingleModerationEvent(moderationID, status)),
+    updateModerationEvent: (status, textCleaned, textRaw) =>
+        dispatch(
+            updateSingleModerationEvent(
+                moderationID,
+                status,
+                textCleaned,
+                textRaw,
+            ),
+        ),
     createProductionLocation: () =>
         dispatch(createProductionLocationFromModerationEvent(moderationID)),
     confirmPotentialMatch: osId =>
diff --git a/src/react/src/components/Dashboard/RejectModerationEventDialog.jsx b/src/react/src/components/Dashboard/RejectModerationEventDialog.jsx
new file mode 100644
index 000000000..e75dc0529
--- /dev/null
+++ b/src/react/src/components/Dashboard/RejectModerationEventDialog.jsx
@@ -0,0 +1,148 @@
+import React, { useState, useMemo } from 'react';
+import { bool, func, object } from 'prop-types';
+
+import { EditorState, convertToRaw } from 'draft-js';
+import draftToHtml from 'draftjs-to-html';
+import { Editor } from 'react-draft-wysiwyg';
+import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
+
+import { withStyles } from '@material-ui/core/styles';
+import {
+    Button,
+    Dialog,
+    DialogActions,
+    DialogContent,
+    DialogTitle,
+    InputLabel,
+    Typography,
+    Tooltip,
+} from '@material-ui/core';
+
+import { MODERATION_STATUSES_ENUM } from '../../util/constants';
+import { makeRejectModerationEventDialogStyles } from '../../util/styles';
+
+const MIN_TEXT_INPUT_LENGTH = 30;
+
+const RejectModerationEventDialog = ({
+    updateModerationEvent,
+    isOpenDialog,
+    closeDialog,
+    classes,
+}) => {
+    const [editorState, setEditorState] = useState(EditorState.createEmpty());
+    const [isTooltipOpen, setIsTooltipOpen] = useState(false);
+
+    const { cleanedText, htmlContent } = useMemo(() => {
+        const contentState = editorState.getCurrentContent();
+        return {
+            cleanedText: contentState.getPlainText(),
+            htmlContent: draftToHtml(convertToRaw(contentState)),
+        };
+    }, [editorState]);
+
+    const handleEditorChange = content => {
+        setEditorState(content);
+    };
+
+    const handleAction = () => {
+        updateModerationEvent(
+            MODERATION_STATUSES_ENUM.REJECTED,
+            cleanedText,
+            htmlContent,
+        );
+        closeDialog();
+    };
+
+    const handleTooltipClose = () => {
+        setIsTooltipOpen(false);
+    };
+
+    const handleTooltipOpen = () => {
+        setIsTooltipOpen(true);
+    };
+
+    const isRejectDisabled =
+        cleanedText.replace(/\s/g, '').length < MIN_TEXT_INPUT_LENGTH;
+
+    return (
+        <Dialog
+            open={isOpenDialog}
+            onClose={closeDialog}
+            aria-labelledby="contribution-action-dialog-title"
+            aria-describedby="contribution-action-dialog-description"
+            classes={{
+                paper: classes.dialogPaperStyles,
+            }}
+        >
+            <DialogTitle>Reject this Moderation Event?</DialogTitle>
+            <DialogContent>
+                <InputLabel htmlFor="dialog-wysiwyg">
+                    <Typography variant="body2">
+                        Keep the instructions brief and action orientated.
+                        (Suggested opening, &quot;Just a few fixes are
+                        needed.&quot;)
+                    </Typography>
+                </InputLabel>
+                <div
+                    id="dialog-wysiwyg"
+                    className={classes.editorContainerStyles}
+                >
+                    <Editor
+                        editorState={editorState}
+                        onEditorStateChange={handleEditorChange}
+                        editorClassName={classes.editorStyles}
+                        toolbar={{
+                            options: [
+                                'inline',
+                                'blockType',
+                                'list',
+                                'link',
+                                'history',
+                            ],
+                        }}
+                    />
+                </div>
+            </DialogContent>
+            <DialogActions className={classes.dialogActionsStyles}>
+                <Button
+                    variant="outlined"
+                    color="primary"
+                    onClick={closeDialog}
+                    className={classes.buttonBaseStyles}
+                >
+                    Cancel
+                </Button>
+                <Tooltip
+                    title="Please provide a message with at least 30 characters."
+                    placement="top"
+                    open={isTooltipOpen && isRejectDisabled}
+                    onClose={handleTooltipClose}
+                    onOpen={handleTooltipOpen}
+                >
+                    <span>
+                        <Button
+                            variant="contained"
+                            color="secondary"
+                            onClick={handleAction}
+                            className={classes.buttonBaseStyles}
+                            disabled={isRejectDisabled}
+                        >
+                            Reject
+                        </Button>
+                    </span>
+                </Tooltip>
+            </DialogActions>
+        </Dialog>
+    );
+};
+
+RejectModerationEventDialog.propTypes = {
+    updateModerationEvent: func.isRequired,
+    isOpenDialog: bool.isRequired,
+    closeDialog: func.isRequired,
+    classes: object.isRequired,
+};
+
+export default withStyles(makeRejectModerationEventDialogStyles)(
+    RejectModerationEventDialog,
+);
diff --git a/src/react/src/util/styles.js b/src/react/src/util/styles.js
index d2567d941..53fa588c1 100644
--- a/src/react/src/util/styles.js
+++ b/src/react/src/util/styles.js
@@ -1575,3 +1575,30 @@ export const makeAddLocationStyles = theme =>
             },
         }),
     });
+
+export const makeRejectModerationEventDialogStyles = () =>
+    Object.freeze({
+        dialogPaperStyles: Object.freeze({
+            overflowX: 'hidden',
+        }),
+        editorContainerStyles: Object.freeze({
+            border: `1px solid ${COLOURS.GREY}`,
+            borderRadius: '5px',
+            minHeight: '320px',
+            marginTop: '10px',
+            padding: '5px',
+        }),
+        editorStyles: Object.freeze({
+            padding: '5px',
+            maxHeight: '230px',
+            minHeight: '230px',
+        }),
+        dialogActionsStyles: Object.freeze({
+            display: 'flex',
+            justifyContent: 'space-evenly',
+            paddingBottom: '10px',
+        }),
+        buttonBaseStyles: Object.freeze({
+            width: '150px',
+        }),
+    });
diff --git a/src/react/yarn.lock b/src/react/yarn.lock
index a2ce9b4f3..72c045335 100644
--- a/src/react/yarn.lock
+++ b/src/react/yarn.lock
@@ -5330,6 +5330,11 @@ classnames@^2.2.5:
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
   integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
 
+classnames@^2.2.6:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
+  integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
+
 clean-css@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
@@ -5637,6 +5642,11 @@ core-js@^2.4.0:
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
   integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
 
+core-js@^3.6.4:
+  version "3.40.0"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.40.0.tgz#2773f6b06877d8eda102fc42f828176437062476"
+  integrity sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==
+
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -5718,6 +5728,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+cross-fetch@^3.0.4:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3"
+  integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==
+  dependencies:
+    node-fetch "^2.7.0"
+
 cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -6488,6 +6505,25 @@ dotenv@8.2.0:
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
   integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
 
+draft-js@^0.11.7:
+  version "0.11.7"
+  resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.11.7.tgz#be293aaa255c46d8a6647f3860aa4c178484a206"
+  integrity sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg==
+  dependencies:
+    fbjs "^2.0.0"
+    immutable "~3.7.4"
+    object-assign "^4.1.1"
+
+draftjs-to-html@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/draftjs-to-html/-/draftjs-to-html-0.9.1.tgz#1c870fbb588d2390204cb4d0ee7e04ad0c709969"
+  integrity sha512-fFstE6+IayaVFBEvaFt/wN8vdj8FsTRzij7dy7LI9QIwf5LgfHFi9zSpvCg+feJ2tbYVqHxUkjcibwpsTpgFVQ==
+
+draftjs-utils@^0.10.2:
+  version "0.10.2"
+  resolved "https://registry.yarnpkg.com/draftjs-utils/-/draftjs-utils-0.10.2.tgz#a7f16d2c1c174ac38ba3bbf700c256f176b2699c"
+  integrity sha512-EstHqr3R3JVcilJrBaO/A+01GvwwKmC7e4TCjC7S94ZeMh4IVmf60OuQXtHHpwItK8C2JCi3iljgN5KHkJboUg==
+
 duplexer@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
@@ -7464,6 +7500,11 @@ fb-watchman@^2.0.0:
   dependencies:
     bser "2.1.1"
 
+fbjs-css-vars@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8"
+  integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==
+
 fbjs@^0.8.1, fbjs@^0.8.16:
   version "0.8.17"
   resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
@@ -7477,6 +7518,20 @@ fbjs@^0.8.1, fbjs@^0.8.16:
     setimmediate "^1.0.5"
     ua-parser-js "^0.7.18"
 
+fbjs@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-2.0.0.tgz#01fb812138d7e31831ed3e374afe27b9169ef442"
+  integrity sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ==
+  dependencies:
+    core-js "^3.6.4"
+    cross-fetch "^3.0.4"
+    fbjs-css-vars "^1.0.0"
+    loose-envify "^1.0.0"
+    object-assign "^4.1.0"
+    promise "^7.1.1"
+    setimmediate "^1.0.5"
+    ua-parser-js "^0.7.18"
+
 figgy-pudding@^3.5.1:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
@@ -8270,6 +8325,11 @@ html-minifier-terser@^5.0.1:
     relateurl "^0.2.7"
     terser "^4.6.3"
 
+html-to-draftjs@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/html-to-draftjs/-/html-to-draftjs-1.5.0.tgz#0df0eabf429deaedb63f5c859889e2c983606e86"
+  integrity sha512-kggLXBNciKDwKf+KYsuE+V5gw4dZ7nHyGMX9m0wy7urzWjKGWyNFetmArRLvRV0VrxKN70WylFsJvMTJx02OBQ==
+
 html-webpack-plugin@4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.5.0.tgz#625097650886b97ea5dae331c320e3238f6c121c"
@@ -8465,6 +8525,11 @@ immutable@^4.0.0:
   resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
   integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
 
+immutable@~3.7.4:
+  version "3.7.6"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b"
+  integrity sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==
+
 import-cwd@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@@ -9999,6 +10064,13 @@ lines-and-columns@^1.1.6:
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
   integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
+linkify-it@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf"
+  integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==
+  dependencies:
+    uc.micro "^1.0.1"
+
 load-json-file@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
@@ -10606,6 +10678,13 @@ node-fetch@^1.0.1:
     encoding "^0.1.11"
     is-stream "^1.0.1"
 
+node-fetch@^2.7.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+  integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+  dependencies:
+    whatwg-url "^5.0.0"
+
 node-forge@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@@ -12471,6 +12550,17 @@ react-dom@16.13.1:
     prop-types "^15.6.2"
     scheduler "^0.19.1"
 
+react-draft-wysiwyg@^1.15.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/react-draft-wysiwyg/-/react-draft-wysiwyg-1.15.0.tgz#d5b4173991033859b9e161c883889ddc00909a57"
+  integrity sha512-p1cYZcWc6/ALFBVksbFoCM3b29fGQDlZLIMrXng0TU/UElxIOF2/AWWo4L5auIYVhmqKTZ0NkNjnXOzGGuxyeA==
+  dependencies:
+    classnames "^2.2.6"
+    draftjs-utils "^0.10.2"
+    html-to-draftjs "^1.5.0"
+    linkify-it "^2.2.0"
+    prop-types "^15.7.2"
+
 react-error-boundary@^3.1.0:
   version "3.1.4"
   resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
@@ -14596,6 +14686,11 @@ tr46@^3.0.0:
   dependencies:
     punycode "^2.1.1"
 
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
 tryer@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
@@ -14749,6 +14844,11 @@ ua-parser-js@^0.7.18:
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832"
   integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==
 
+uc.micro@^1.0.1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
+  integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
+
 unbox-primitive@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.0.tgz#eeacbc4affa28e9b3d36b5eaeccc50b3251b1d3f"
@@ -15130,6 +15230,11 @@ wbuf@^1.1.0, wbuf@^1.7.3:
   dependencies:
     minimalistic-assert "^1.0.0"
 
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
 webidl-conversions@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@@ -15328,6 +15433,14 @@ whatwg-url@^11.0.0:
     tr46 "^3.0.0"
     webidl-conversions "^7.0.0"
 
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
 whatwg-url@^8.0.0:
   version "8.4.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837"


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (11)</summary><blockquote>

<details>
<summary>src/react/package.json (1)</summary><blockquote>

`17-18`: **Content sanitization strategy required for rich text editor integration.**

When implementing rich text editors like Draft.js, it's crucial to establish a proper HTML sanitization strategy to prevent XSS attacks, especially when the content will be displayed with the `safe` filter in Django templates.


Consider implementing:

1. Client-side sanitization using a library like DOMPurify before sending to the server
2. Server-side sanitization using Django's built-in capabilities or libraries like bleach
3. Restricting the allowed HTML tags and attributes to a safe subset

This sanitization should be implemented in the component that utilizes these packages (likely the new `ConfirmActionDialog` component).


Also applies to: 33-33

</blockquote></details>
<details>
<summary>src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py (2)</summary><blockquote>

`13-20`: **Consider adding default values for the new fields.**

The migration adds new fields to an existing table without default values. This could cause issues if there are existing records in the database.


Consider modifying the migration to include default values:

```diff
 migrations.AddField(
     model_name='moderationevent',
     name='action_reason_text_cleaned',
     field=models.TextField(
         blank=True,
+        default='',
         help_text='Cleaned version of the action reason text.',
     ),
 ),
```

---

`21-28`: **Consider adding default values for the new fields.**

Similar to the previous field, consider adding a default value to the `action_reason_text_raw` field as well.


```diff
 migrations.AddField(
     model_name='moderationevent',
     name='action_reason_text_raw',
     field=models.TextField(
         blank=True,
+        default='',
         help_text='Raw version of the action reason text.',
     ),
 ),
```

</blockquote></details>
<details>
<summary>src/django/api/models/moderation_event.py (2)</summary><blockquote>

`150-153`: **Consider adding default value to text field.**

The `action_reason_text_cleaned` field is defined as blank=True, but without a default value. This is inconsistent with other similar fields in the model (like the `source` field on line 139) and could lead to unexpected behavior.


```diff
 action_reason_text_cleaned = models.TextField(
     blank=True,
+    default='',
     help_text='Cleaned version of the action reason text.'
 )
```

---

`155-158`: **Consider adding default value to text field.**

Similarly, the `action_reason_text_raw` field should have a default value for consistency with other optional text fields in the model.


```diff
 action_reason_text_raw = models.TextField(
     blank=True,
+    default='',
     help_text='Raw version of the action reason text.'
 )
```

</blockquote></details>
<details>
<summary>src/react/src/util/styles.js (1)</summary><blockquote>

`1579-1604`: **Good implementation of styles for the confirmation dialog.**

The `makeConfirmActionDialogStyles` function is well structured and follows the existing pattern of using `Object.freeze()` for immutability. The styles provide appropriate constraints for the rich text editor and dialog layout.


Consider adding responsive styles similar to other dialogs in the codebase. For example, you might want to adjust the width or padding of elements at different breakpoints:

```diff
export const makeConfirmActionDialogStyles = theme =>
    Object.freeze({
        dialogPaperStyles: Object.freeze({
            overflowX: 'hidden',
+           [theme.breakpoints.down('sm')]: {
+               width: '100%',
+               margin: '10px',
+           },
        }),
        editorContainerStyles: Object.freeze({
            border: `1px solid ${COLOURS.GREY}`,
            borderRadius: '5px',
            minHeight: '320px',
            marginTop: '10px',
            padding: '5px',
+           [theme.breakpoints.down('sm')]: {
+               minHeight: '250px',
+           },
        }),
        // Other styles remain the same
    });
```

This would improve the mobile experience for users when rejecting contributions.

</blockquote></details>
<details>
<summary>src/django/api/views/v1/moderation_events.py (1)</summary><blockquote>

`130-138`: **Improved error handling for validation errors.**

The change improves the error response structure by first checking for `non_field_errors` and falling back to the full error object if needed. This results in more targeted error messages for the client.


Consider adding a comment explaining the reason for this specific error handling approach. This would help future developers understand the intent:

```diff
if not serializer.is_valid():
+   # First try to get non_field_errors (commonly from our custom validators)
+   # If not present, include all errors as a list for consistent response structure
    errors = serializer.errors.get(
        "non_field_errors",
        [serializer.errors]
    )
    raise ValidationError(
        {
            "detail": APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR,
            "errors": errors,
        }
    )
```

</blockquote></details>
<details>
<summary>src/react/src/__tests__/components/DashboardContributionRecord.test.js (1)</summary><blockquote>

`301-320`: **Good test coverage for dialog open/close behavior.**

These tests verify the basic functionality of opening and closing the confirmation dialog.


Consider adding a test for the actual submission of the rejection with text content. This would ensure the full workflow is tested:

```javascript
test('submits rejection with text content when confirm button is clicked', async () => {
  // Mock the actual ConfirmActionDialog implementation for this test
  jest.resetModules();
  jest.mock('../../components/Dashboard/ConfirmActionDialog', () => ({
    __esModule: true,
    default: ({ isOpenDialog, closeDialog, confirmAction }) => (
      <div>
        {isOpenDialog && (
          <>
            <textarea data-testid="rejection-text" />
            <button type="button" onClick={() => confirmAction('cleaned text', 'raw text')}>
              Confirm Rejection
            </button>
          </>
        )}
      </div>
    ),
  }));
  
  // Re-import the component to use the updated mock
  const { default: DashboardContributionRecord } = require('../../components/Dashboard/DashboardContributionRecord');
  
  const { getByRole, getByTestId } = renderWithProviders(
    <Router>
      <DashboardContributionRecord {...routeProps} />
    </Router>,
    { preloadedState }
  );
  
  // Open dialog
  const rejectButton = getByRole('button', { name: /Reject Contribution/i });
  fireEvent.click(rejectButton);
  
  // Submit rejection
  const confirmButton = getByRole('button', { name: /Confirm Rejection/i });
  fireEvent.click(confirmButton);
  
  // Verify updateSingleModerationEvent was called with correct parameters
  expect(updateSingleModerationEvent).toHaveBeenCalledWith(
    '9865b3a3-6eb0-4475-a75a-60002ade2eb2',
    'REJECTED',
    'cleaned text',
    'raw text'
  );
});
```

This would provide more comprehensive test coverage of the feature.

</blockquote></details>
<details>
<summary>src/django/api/serializers/v1/moderation_event_update_serializer.py (2)</summary><blockquote>

`102-106`: **Enhance error message precision**

The error message for length validation would be more precise if it included the actual minimum required length from the constant rather than hardcoding "30".

```diff
-                    "detail": "This field must be at least 30 characters."
+                    "detail": f"This field must be at least {self.MIN_REASON_TEXT_LENGTH} characters."
```

---

`117-120`: **Enhance error message precision**

Same recommendation for the raw field error message - use the constant instead of hardcoding "30".

```diff
-                    "detail": "This field must be at least 30 characters."
+                    "detail": f"This field must be at least {self.MIN_REASON_TEXT_LENGTH} characters."
```

</blockquote></details>
<details>
<summary>src/django/api/tests/test_moderation_events_update.py (1)</summary><blockquote>

`111-221`: **Consider adding test for non-rejection status changes.**

While the current tests thoroughly cover the rejection scenarios, it would be valuable to add a test that confirms reason text fields are not required for other status changes (e.g., APPROVED).


```python
def test_moderation_event_approved_without_reason_text(self):
    self.client.login(
        email=self.superemail,
        password=self.superpassword
    )
    response = self.client.patch(
        "/api/v1/moderation-events/{}/"
        .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"),
        data=json.dumps({"status": "APPROVED"}),
        content_type="application/json"
    )

    self.assertEqual(200, response.status_code)
    self.moderation_event.refresh_from_db()
    self.assertEqual(self.moderation_event.status, "APPROVED")
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 99c80aea2068fea46389026e8d5022ddf8c7c9bd and 38e834946c27e25ac030f3640ea9844b5204ed3a.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `src/react/yarn.lock` is excluded by `!**/yarn.lock`, `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (13)</summary>

* `src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py` (1 hunks)
* `src/django/api/models/moderation_event.py` (1 hunks)
* `src/django/api/serializers/v1/moderation_event_update_serializer.py` (3 hunks)
* `src/django/api/templates/mail/slc_contribution_rejected_body.html` (1 hunks)
* `src/django/api/tests/test_moderation_events_update.py` (3 hunks)
* `src/django/api/views/v1/moderation_events.py` (1 hunks)
* `src/react/package.json` (2 hunks)
* `src/react/src/__tests__/components/ConfirmActionDialog.test.js` (1 hunks)
* `src/react/src/__tests__/components/DashboardContributionRecord.test.js` (3 hunks)
* `src/react/src/actions/dashboardContributionRecord.js` (1 hunks)
* `src/react/src/components/Dashboard/ConfirmActionDialog.jsx` (1 hunks)
* `src/react/src/components/Dashboard/DashboardContributionRecord.jsx` (6 hunks)
* `src/react/src/util/styles.js` (1 hunks)

</details>

<details>
<summary>🔇 Additional comments (38)</summary><blockquote>

<details>
<summary>src/react/package.json (1)</summary>

`17-18`: <details>
<summary>❓ Verification inconclusive</summary>

**Version security and compatibility check needed for rich text editing packages.**

Adding Draft.js and related packages for rich text editing is appropriate for implementing a text editor for rejection messages. However, these packages should be audited for security vulnerabilities and compatibility issues.


Let's check the latest versions and any known security issues for these packages:





Also applies to: 33-33

---

🏁 Script executed:

```shell
#!/bin/bash
# Check for the latest versions and known vulnerabilities in the rich text editing packages

echo "Checking latest versions for draft-js, draftjs-to-html, and react-draft-wysiwyg..."
npm view draft-js version
npm view draftjs-to-html version
npm view react-draft-wysiwyg version

echo "Checking for known vulnerabilities..."
npm audit --json | grep -E "draft-js|draftjs-to-html|react-draft-wysiwyg" -A 10
```

Length of output: 812

---



</details>

**Audit and Version Check for Rich Text Editing Packages**

- The installed versions in `src/react/package.json` for `draft-js` (`0.11.7`) and `draftjs-to-html` (`0.9.1`) match the latest available versions per `npm view`.
- The vulnerability check using `npm audit` did not complete due to the absence of a lockfile (error: “ENOLOCK”). To properly assess vulnerabilities, please generate a lockfile (e.g., run `npm i --package-lock-only`) and then rerun the audit.

Also applies to similar package entries (e.g., at line 33).

</details>
<details>
<summary>src/react/src/__tests__/components/DashboardContributionRecord.test.js (1)</summary>

`34-45`: **Good mock implementation for ConfirmActionDialog.**

The mock correctly simulates the component's interface with `isOpenDialog` and `closeDialog` props.

</details>
<details>
<summary>src/react/src/__tests__/components/ConfirmActionDialog.test.js (10)</summary>

`1-7`: **LGTM: Import and setup look good**

The imports and initial setup for the test file are well-structured and include all necessary dependencies.

---

`11-28`: **Well-implemented mock for the WYSIWYG editor**

The mock implementation for the `react-draft-wysiwyg` editor is well done. It properly simulates the editor's behavior by converting input to the appropriate Draft.js state.

---

`30-41`: **Good mock implementation for Material-UI Tooltip**

The mock implementation of the Material-UI Tooltip component is well-crafted, allowing proper testing of tooltip visibility based on hover events.

---

`44-54`: **Test setup is well-structured**

The test suite setup is clean with proper default props and jest cleanup after each test.

---

`55-64`: **LGTM: Basic rendering test is thorough**

The first test properly verifies that the dialog renders with the correct title, instructions, and buttons.

---

`66-72`: **LGTM: Cancel button test is correct**

This test correctly verifies that the cancel button calls the closeDialog function when clicked.

---

`74-81`: **LGTM: Disabled button state test is appropriate**

The test correctly verifies that the reject button is initially disabled when the editor is empty.

---

`83-107`: **LGTM: Validation and submission flow is well-tested**

This test thoroughly checks:
1. The initial disabled state
2. The enabled state after entering valid text
3. The correct function calls with expected parameters when submitting

The validation of both the cleaned text and HTML content is properly tested.

---

`109-127`: **LGTM: Tooltip visibility test is comprehensive**

This test correctly verifies the tooltip's visibility when hovering over the disabled reject button.

---

`129-143`: **LGTM: Enabled button tooltip behavior test is thorough**

This test accurately verifies that the tooltip does not appear when the reject button is enabled.

</details>
<details>
<summary>src/react/src/components/Dashboard/DashboardContributionRecord.jsx (6)</summary>

`22-22`: **LGTM: Import for new component**

The import for the new ConfirmActionDialog component is correctly added.

---

`90-92`: **LGTM: State handling for dialog visibility**

The state variable for managing the dialog visibility is properly implemented.

---

`168-174`: **LGTM: Dialog handlers are well-implemented**

The functions to open and close the confirmation dialog are properly implemented and named descriptively.

---

`420-420`: **LGTM: Updated event handler for the Reject button**

The click handler for the Reject button now correctly calls the function to open the confirmation dialog.

---

`452-456`: **LGTM: Dialog component integration**

The ConfirmActionDialog component is properly integrated with the required props.

---

`515-523`: **LGTM: Updated action dispatcher for moderation events**

The signature for updateModerationEvent is correctly updated to include the text parameters needed for rejection reasons.

</details>
<details>
<summary>src/react/src/components/Dashboard/ConfirmActionDialog.jsx (9)</summary>

`1-8`: **LGTM: Imports are comprehensive**

All necessary imports for React, prop-types, and Draft.js/WYSIWYG editor are properly included.

---

`9-22`: **LGTM: Material-UI imports are well-organized**

The Material-UI components are properly imported and grouped.

---

`24-24`: **Good practice: Constant for minimum text length**

Defining the minimum text length as a constant is a good practice for maintainability.

---

`26-41`: **LGTM: Component structure and state management**

The component is well-structured with proper props and state management. Using useMemo for derived values is a good optimization.

---

`47-54`: **LGTM: Action handler properly updates state and closes dialog**

The handleAction function correctly calls updateModerationEvent with the appropriate parameters and closes the dialog.

---

`56-65`: **LGTM: Tooltip handling and validation logic**

The tooltip state management and text validation logic are well-implemented.

---

`67-104`: **Dialog content is well-structured**

The dialog content is well-organized with proper labeling and editor configuration. The toolbar options are appropriately limited to relevant formatting options.

---

`106-134`: **LGTM: Dialog actions with proper validation**

The dialog actions are well-implemented with:
1. Clear cancel button
2. Properly disabled reject button based on text length
3. Tooltip showing validation requirements

---

`139-147`: **LGTM: PropTypes validation and export**

The component's PropTypes are properly defined, and the component is correctly exported with styles.

</details>
<details>
<summary>src/django/api/serializers/v1/moderation_event_update_serializer.py (5)</summary>

`14-14`: **LGTM: Constant for minimum text length**

Defining the minimum text length as a constant is good practice for maintainability and consistency.

---

`16-17`: **LGTM: New fields for rejection reason**

The new fields for storing the cleaned and raw text are properly added as write-only fields.

---

`40-41`: **LGTM: Fields added to serializer Meta**

The new fields are correctly added to the serializer's Meta.fields list.

---

`87-126`: **LGTM: Comprehensive validation logic**

The validation logic is well-implemented:
1. It checks specifically for the REJECTED status
2. It verifies both fields are present
3. It validates the minimum length requirement
4. It provides clear error messages

This ensures data quality and consistency.

---

`133-138`: **LGTM: Update method properly stores the new fields**

The update method correctly assigns the validated data to the instance fields when rejecting a moderation event.

</details>
<details>
<summary>src/django/api/tests/test_moderation_events_update.py (6)</summary>

`2-13`: **Appropriate imports added for new test functionality.**

The imports are well-organized and include all the necessary components for the new test cases, especially the signal handling for OpenSearch.

---

`21-24`: **Good practice disconnecting signal in test setup.**

Disconnecting the OpenSearch signal handler in the setUp method is a good practice as it:
1. Prevents side effects during testing
2. Isolates the component being tested
3. Makes tests faster and more reliable

This is especially important when testing components that might trigger external indexing operations.

---

`111-151`: **Well-structured test for valid rejection with reason text.**

The test has been renamed to accurately reflect its purpose and includes proper validation of the new reason text fields. The assertions properly verify that the fields are stored correctly in the database after a successful update.

---

`153-188`: **Comprehensive validation test for invalid reason text.**

This test correctly verifies that providing reason text that's too short results in a 400 response with appropriate error messages. It also confirms that the moderation event status remains unchanged after the failed update.

---

`189-221`: **Good validation test for missing reason text.**

This test properly verifies that omitting required reason text fields when rejecting a moderation event results in a 400 response with appropriate error messages. The assertions are thorough and validate both the response format and that the database state remains unchanged.

---

`129-134`: **Verify rejection reason is included in email content.**

The test checks for the email subject but doesn't verify if the rejection reason is included in the email body. If the reason text should be part of the email sent to the contributor, it would be valuable to add an assertion that checks for it.


```python
# Add after line 134
self.assertIn(
    "Cleaned reason text with 30 characters",
    email.body,
    "Rejection reason should be included in the email body"
)
```

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T19:30:41Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (7)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (7)</summary><blockquote>

`6-7`: **Internal Instruction Notice**  
The added text "Use the format below to document information about the new release." appears to be an internal formatting instruction rather than end-user release note content. Consider removing or rephrasing it so that only the finalized release information is presented.

---

`14-16`: **Database Changes Placeholder**  
The "### Database changes" section currently contains a placeholder ("*Describe high-level database changes.*"). Please update this section with specific database modifications (if any) related to this release, or remove the placeholder if not applicable.

---

`17-19`: **Migration Details and Markdown Style**  
The migration details for `0167_add_moderationevent_action_reason_text_fields.py` are informative and clearly stated.  
*However*, note that the heading "#### Migrations:" ends with a colon. To comply with markdown style guidelines (MD026), consider removing the trailing punctuation. For example:  
```diff
-#### Migrations:
+#### Migrations
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

17-17: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

</details>

</details>

---

`20-22`: **Schema Changes Section**  
The "#### Schema changes" section properly lists the changes with a link to the OSDEV-1782 ticket. Similar to the previous comment, consider removing the trailing colon from the heading for consistency with markdown best practices.

---

`26-28`: **Architecture/Environment Changes Placeholder**  
This section currently contains placeholder text ("*Describe architecture/environment changes here.*"). If there are relevant changes in architecture or environment settings for this release, please include them. Otherwise, consider removing the placeholder to keep the release notes concise.

---

`29-31`: **Bugfix Section Placeholder**  
The "### Bugfix" section is also a placeholder ("*Describe bugfix here.*"). Provide details if there are any bug fixes in this release, or remove the section if it does not apply.

---

`35-39`: **Release Instructions & Markdown Linting Suggestions**  
The release instructions list ("* `migrate`" and "* `reindex_database`") is clear. However, note the following markdown style suggestions:  
- The heading "### Release instructions:" ends with a colon; consider removing the colon to meet MD026 guidelines.  
- The unordered list under this heading appears to have indentations (lines 37-38) that do not match the expected two-space indentation per markdownlint (MD007). Please adjust to conform to the expected style.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

35-35: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

---

37-37: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

---

38-38: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 38e834946c27e25ac030f3640ea9844b5204ed3a and f0d6aec649c4bc074996c19d67633a6b8a9404fc.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `doc/release/RELEASE-NOTES.md` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

<details>
<summary>doc/release/RELEASE-NOTES.md</summary>

17-17: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

---

35-35: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

---

37-37: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

---

38-38: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-dd-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-django-cov
* GitHub Check: run-fe-code-quality
* GitHub Check: run-django-code-quality
* GitHub Check: get-base-branch-fe-cov

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (4)</summary>

`8-9`: **Release Version Heading**  
The heading "## Release 2.0.0" is clear and appropriately signals the new release section.

---

`10-13`: **Introduction Section Clarity**  
The introduction clearly specifies the product name and release date ("* Product name: Open Supply Hub" and "* Release date: March 22, 2025"). Verify that the release date aligns with the intended deployment schedule.

---

`23-25`: **Code/API Changes Description**  
The "### Code/API changes" section documents the additional validation for the new rejection message fields comprehensively. This update supports the requirement that the fields be required when status is set to 'REJECTED' and enforces a minimum length of 30 characters.

---

`32-34`: **What's New Feature Detail**  
The "### What's new" section clearly describes the new confirmation dialog for rejection actions, highlighting its features such as the WYSIWYG text editor and the minimum 30-character requirement. This documentation is clear and aligns well with the PR objectives.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T20:16:20Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T12:22:36Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-05T12:23:23Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T14:03:06Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-05T14:03:35Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T12:06:16Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T12:08:02Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/react/src/__tests__/components/DashboardContributionRecord.test.js (1)</summary><blockquote>

`301-320`: **Consider adding a test for the actual rejection flow with text message.**

While the current tests cover opening and closing the dialog, they don't verify the complete rejection flow with text submission. Consider adding a test that:
1. Opens the dialog
2. Simulates entering text (meeting the 30-character minimum)
3. Submits the rejection
4. Verifies that `updateSingleModerationEvent` is called with the correct parameters (status='REJECTED' and the text message)

This would provide more comprehensive test coverage for the new feature.

</blockquote></details>
<details>
<summary>doc/release/RELEASE-NOTES.md (3)</summary><blockquote>

`11-13`: **Database Changes Section – Enhance Detail**  
The new "### Database changes" section currently contains a placeholder line for the high-level summary. Consider adding more detailed information (if available) about the impact of the new migration—such as why adding the fields `action_reason_text_cleaned` and `action_reason_text_raw` is important, and what benefits or considerations it brings to the project.

---

`14-16`: **Markdown Styling: Remove Trailing Punctuation**  
The heading "#### Migrations:" includes a trailing colon, which does not comply with the markdownlint MD026 guideline. To fix this, please remove the colon so that the heading reads "#### Migrations".  


```diff
-#### Migrations:
+#### Migrations
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

14-14: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

</details>

</details>

---

`23-24`: **Architecture/Environment Changes Section – Placeholder Check**  
The "### Architecture/Environment changes" section still contains a placeholder ("*Describe architecture/environment changes here.*"). If there are no actual changes in this area, consider removing the placeholder. Otherwise, please provide a brief summary of any modifications that affect deployment or infrastructure, ensuring the release notes remain comprehensive.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f0d6aec649c4bc074996c19d67633a6b8a9404fc and ab0fb135790d7d8573f3610b4deea0bfd9fe7cd0.

</details>

<details>
<summary>📒 Files selected for processing (9)</summary>

* `doc/release/RELEASE-NOTES.md` (1 hunks)
* `src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py` (1 hunks)
* `src/django/api/models/moderation_event.py` (2 hunks)
* `src/django/api/templates/mail/slc_contribution_rejected_body.html` (2 hunks)
* `src/django/oar/settings.py` (2 hunks)
* `src/django/requirements.txt` (1 hunks)
* `src/react/src/__tests__/components/DashboardContributionRecord.test.js` (3 hunks)
* `src/react/src/components/Dashboard/DashboardContributionRecord.jsx` (6 hunks)
* `src/react/src/util/styles.js` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (4)</summary>

* src/django/api/migrations/0167_add_moderationevent_action_reason_text_fields.py
* src/django/api/templates/mail/slc_contribution_rejected_body.html
* src/django/api/models/moderation_event.py
* src/react/src/components/Dashboard/DashboardContributionRecord.jsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

<details>
<summary>doc/release/RELEASE-NOTES.md</summary>

14-14: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (10)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-django-code-quality
* GitHub Check: run-dd-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-django-cov
* GitHub Check: get-base-branch-countries-cov

</details>

<details>
<summary>🔇 Additional comments (11)</summary><blockquote>

<details>
<summary>src/react/src/util/styles.js (1)</summary>

`1579-1604`: **Well-structured styling implementation.**

The new `makeConfirmActionDialogStyles` function follows the established pattern in the file, using descriptive names and `Object.freeze()` to make styles immutable. The styles appropriately define the layout for a confirmation dialog with a text editor, including proper spacing, borders, and fixed dimensions to prevent layout shifts during user interaction.

</details>
<details>
<summary>src/django/oar/settings.py (2)</summary>

`134-134`: **Good addition of django_bleach to INSTALLED_APPS**

Adding django_bleach to INSTALLED_APPS is appropriate for implementing the text sanitization required for rejection messages.

---

`568-582`: **Well-configured Bleach settings for HTML sanitization**

The Bleach configuration is properly set up with:
- A comprehensive list of allowed HTML tags for rich text formatting
- Restricted attributes for links (only href, target, title)
- Security measures enabled (strip_tags and strip_comments)

This configuration provides a good balance between allowing rich text formatting for rejection messages while maintaining security by sanitizing user input.

</details>
<details>
<summary>src/django/requirements.txt (1)</summary>

`46-46`: **Appropriate dependency addition for HTML sanitization**

Adding django-bleach==2.0.0 is consistent with the changes in settings.py and provides the necessary functionality for sanitizing HTML content in rejection messages.

</details>
<details>
<summary>src/react/src/__tests__/components/DashboardContributionRecord.test.js (4)</summary>

`3-3`: **Nice addition to the imports!**

Adding the `fireEvent` import is necessary for the new tests that simulate user interactions with the dialog buttons.

---

`34-45`: **Good mock implementation for the ConfirmActionDialog component.**

The mock correctly simulates the basic functionality needed for testing - showing/hiding based on the `isOpenDialog` prop and handling the close action. This allows testing the dialog's integration with the parent component without implementing all the complex WYSIWYG editor logic.

---

`301-308`: **Test coverage for dialog opening looks good.**

This test properly verifies that clicking the "Reject Contribution" button opens the confirmation dialog.

---

`310-320`: **Test coverage for dialog closing works as expected.**

This test correctly verifies that the dialog can be closed by clicking the "Close Dialog" button.

</details>
<details>
<summary>doc/release/RELEASE-NOTES.md (3)</summary>

`17-19`: **Schema Changes Section – Clear and Informative**  
The "#### Schema changes" section is well-documented. It clearly links to [OSDEV-1782] and explains the addition of the new fields to the `api_moderationevent` table. This section appears complete and meets expectations.

---

`20-22`: **Code/API Changes Section – Well Detailed**  
The update in the "### Code/API changes" section clearly explains that additional validation has been added to the PATCH endpoint for the new fields. This change is clearly aligned with the PR objectives and is documented with the relevant Jira ticket.

---

`26-30`: **Bugfix Section – Clear and Concise**  
The "### Bugfix" section clearly lists the improvements and bug fixes with reference to the corresponding Jira tickets (OSDEV-1806, OSDEV-1787, OSDEV-1789). The descriptions are straightforward and informative.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T12:09:31Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (2)</summary><blockquote>

`11-16`: **Database Changes & Migrations Section – Ensure Consistency in Headings**

The new migration for adding the rejection action fields (i.e. `0167_add_moderationevent_action_reason_text_fields.py`) is clearly described and references OSDEV-1782 appropriately. One minor note: the heading “#### Migrations:” (line 14) includes a trailing colon. To align with markdown style guidelines (e.g. MD026), consider removing the colon for consistency.

```diff
-#### Migrations:
+#### Migrations
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

14-14: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

</details>

</details>

---

`20-23`: **API Changes and Server-side Sanitization for Rejection Action**

The “### Code/API changes” section provides a clear summary of the enhancements for OSDEV-1782:
- It explains the additional validation applied when using the `PATCH api/v1/moderation-events/{moderation_id}` endpoint, including the requirement that the fields must have at least 30 characters when the event is set to 'REJECTED'.
- It also notes that sanitization is now applied (using the Django-Bleach library) to the HTML content stored in `action_reason_text_raw`.

As a minor suggestion, consider rephrasing the sentence “Also was added sanitization on the server side...” to improve clarity, for example: “Additionally, server-side sanitization has been added using the Django-Bleach library for the HTML content stored in the `action_reason_text_raw` field.”

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ab0fb135790d7d8573f3610b4deea0bfd9fe7cd0 and b1561eb6595a1c1ccd28ac088fad32a536e7fb49.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `doc/release/RELEASE-NOTES.md` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

<details>
<summary>doc/release/RELEASE-NOTES.md</summary>

14-14: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-dd-code-quality
* GitHub Check: run-django-code-quality
* GitHub Check: run-fe-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-fe-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-django-cov

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (2)</summary>

`17-19`: **Schema Changes for OSDEV-1782 – Clear and Informative**

The “#### Schema changes” section clearly documents that new fields `action_reason_text_cleaned` and `action_reason_text_raw` have been added to the `api_moderationevent` table, with a proper reference to the OSDEV-1782 ticket. This description is concise and ties the database change to the rejection message functionality.

---

`32-35`: **New Confirmation Dialog for Rejection Action**

The “### What's new” section clearly presents the addition for OSDEV-1782: a confirmation dialog window featuring a WYSIWYG text editor that requires a minimum of 30 characters before enabling the Reject button. The description is precise and informs users about the disabled state of the button and the tooltip guidance provided.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T12:54:43Z
user: login: protsack-stephan
body: Few small comments. Great work!
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-06T13:25:10Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (2)</summary><blockquote>

`22-24`: **API Validation & Sanitization Update for Rejection Action**  
The update to the PATCH endpoint for moderation events now requires `action_reason_text_cleaned` and `action_reason_text_raw` when the status is set to 'REJECTED'. Additionally, server‐side sanitization is applied using Django‑Bleach in the rejection email/template. Please ensure that the minimum length (30 characters) validation and HTML sanitization are fully covered by tests and that potential edge cases (empty or below-threshold inputs) are handled as expected.

---

`59-62`: **'What's New' Section – Duplicate Entry Check & Feature Review**  
In the “What's new” section, there are two entries for the toggle switch button feature (OSDEV-1814) describing the production location info page behavior (lines 60 and 62). This appears to be a duplicate. Please confirm if the redundancy is intentional or if one of these entries should be removed to avoid confusion. Also, verify that the confirmation dialog feature for rejection (OSDEV-1782 on line 61) is clearly distinguished from the toggle switch feature and that its description accurately reflects the UI/UX requirements.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b1561eb6595a1c1ccd28ac088fad32a536e7fb49 and b690be9d2c3fcd73dd8e93d4a722f705a8a81a91.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `doc/release/RELEASE-NOTES.md` (2 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-dd-code-quality
* GitHub Check: run-fe-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-fe-cov
* GitHub Check: get-base-branch-django-cov
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: run-django-code-quality

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>doc/release/RELEASE-NOTES.md (2)</summary>

`16-17`: **Migration Addition Verification**  
The new migration “0167_add_moderationevent_action_reason_text_fields.py” is introduced to add the fields `action_reason_text_cleaned` and `action_reason_text_raw` to the `api_moderationevent` table. Please verify that the naming and constraints conform to project conventions and that corresponding tests (or migration rollback plans) are in place to ensure smooth deployment.

---

`19-20`: **Database Schema Update for Moderation Events**  
The schema change adds the same two fields to the table as noted in the migration. Confirm that the database-level constraints (such as NOT NULL or default values, if applicable) and any needed indexes are defined properly. Also, check for downstream dependencies that might require adjustments now that these fields are available.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T13:29:05Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T13:35:16Z
user: login: VadimKovalenkoSNF
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T13:36:12Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T13:54:07Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/django/api/serializers/v1/moderation_event_update_serializer.py (4)</summary><blockquote>

`108-108`: **Use the constant for error message instead of hardcoded value.**

The error message has a hardcoded "30 characters" while there's a `MIN_REASON_TEXT_LENGTH` constant defined. Using the constant value in the error message would make maintenance easier if the requirement changes in the future.

```diff
-                "detail": "This field must be at least 30 characters."
+                "detail": f"This field must be at least {self.MIN_REASON_TEXT_LENGTH} characters."
```

---

`122-122`: **Use the constant for error message instead of hardcoded value.**

The error message has a hardcoded "30 characters" while there's a `MIN_REASON_TEXT_LENGTH` constant defined. Using the constant value in the error message would make maintenance easier if the requirement changes in the future.

```diff
-                "detail": "This field must be at least 30 characters."
+                "detail": f"This field must be at least {self.MIN_REASON_TEXT_LENGTH} characters."
```

---

`87-128`: **Consider refactoring the validation logic to reduce duplication.**

The validation logic for `action_reason_text_cleaned` and `action_reason_text_raw` is nearly identical, which leads to code duplication. Consider refactoring this into a helper method to improve maintainability.

```diff
    def validate(self, data):
        status = data.get('status')

        if status != ModerationEvent.Status.REJECTED:
            return data

-        cleaned = data.get('action_reason_text_cleaned')
-        raw = data.get('action_reason_text_raw')
        errors = []

-        if not cleaned:
-            errors.append({
-                "field": "action_reason_text_cleaned",
-                "detail": (
-                    "This field is required when rejecting a moderation "
-                    "event."
-                )
-            })
-        elif len(cleaned) < self.MIN_REASON_TEXT_LENGTH:
-            errors.append({
-                "field": "action_reason_text_cleaned",
-                "detail": "This field must be at least 30 characters."
-            })
-
-        if not raw:
-            errors.append({
-                "field": "action_reason_text_raw",
-                "detail": (
-                    "This field is required when rejecting a moderation "
-                    "event."
-                )
-            })
-        elif len(raw) < self.MIN_REASON_TEXT_LENGTH:
-            errors.append({
-                "field": "action_reason_text_raw",
-                "detail": "This field must be at least 30 characters."
-            })
+        self._validate_reason_text_field(data, 'action_reason_text_cleaned', errors)
+        self._validate_reason_text_field(data, 'action_reason_text_raw', errors)

        if errors:
            raise ValidationError(errors)

        return data
+        
+    def _validate_reason_text_field(self, data, field_name, errors):
+        field_value = data.get(field_name)
+        if not field_value:
+            errors.append({
+                "field": field_name,
+                "detail": (
+                    "This field is required when rejecting a moderation "
+                    "event."
+                )
+            })
+        elif len(field_value) < self.MIN_REASON_TEXT_LENGTH:
+            errors.append({
+                "field": field_name,
+                "detail": f"This field must be at least {self.MIN_REASON_TEXT_LENGTH} characters."
+            })
```

---

`136-141`: **Add defensive checks for field existence in validated_data.**

The code assumes that `validated_data` will always have the required fields when the status is 'REJECTED', which is likely true due to the validation. However, for robustness, consider adding defensive checks before accessing these fields.

```diff
                instance.action_type = ModerationEvent.ActionType.REJECTED
                instance.action_perform_by = self.__moderator
-                instance.action_reason_text_cleaned = validated_data[
-                    'action_reason_text_cleaned'
-                ]
-                instance.action_reason_text_raw = validated_data[
-                    'action_reason_text_raw'
-                ]
+                instance.action_reason_text_cleaned = validated_data.get(
+                    'action_reason_text_cleaned', ''
+                )
+                instance.action_reason_text_raw = validated_data.get(
+                    'action_reason_text_raw', ''
+                )
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f38c95f1391bb1f8e2795b8dfae70638eaa114d2 and 70fd99d8d2cdf06e44e1cdae7f23968436828b67.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/django/api/serializers/v1/moderation_event_update_serializer.py` (3 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-dd-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: run-fe-code-quality
* GitHub Check: run-django-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-django-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-fe-cov

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>src/django/api/serializers/v1/moderation_event_update_serializer.py (1)</summary>

`90-91`: **LGTM - Implemented the suggested reverse if statement.**

This code successfully implements the previous review suggestion to use a reverse if statement, which makes the code cleaner and more readable.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T13:56:17Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:08:16Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/react/src/components/Dashboard/RejectModerationEventDialog.jsx (1)</summary><blockquote>

`64-66`: **Consider improving the text validation logic**

The current validation removes all whitespace before checking length, which could allow submissions that are mostly spaces with very little actual content.

Consider a more robust validation:

```diff
-const isRejectDisabled =
-    cleanedText.replace(/\s/g, '').length < MIN_TEXT_INPUT_LENGTH;
+const isRejectDisabled =
+    cleanedText.trim().length < MIN_TEXT_INPUT_LENGTH || 
+    cleanedText.replace(/\s+/g, ' ').trim().length < MIN_TEXT_INPUT_LENGTH;
```

This would ensure both the total length and the content (after normalizing whitespace) meet the minimum requirement.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 70fd99d8d2cdf06e44e1cdae7f23968436828b67 and b49983fb74d1657754248530eaa0f184b362961b.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `src/react/src/__tests__/components/DashboardContributionRecord.test.js` (3 hunks)
* `src/react/src/__tests__/components/RejectModerationEventDialog.test.js` (1 hunks)
* `src/react/src/components/Dashboard/DashboardContributionRecord.jsx` (6 hunks)
* `src/react/src/components/Dashboard/RejectModerationEventDialog.jsx` (1 hunks)
* `src/react/src/util/styles.js` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* src/react/src/__tests__/components/DashboardContributionRecord.test.js
* src/react/src/components/Dashboard/DashboardContributionRecord.jsx

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-fe-code-quality
* GitHub Check: run-dd-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-fe-cov
* GitHub Check: get-base-branch-django-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: run-django-code-quality

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>src/react/src/util/styles.js (1)</summary>

`1578-1604`: **LGTM - Style function for dialog follows established pattern**

The new `makeRejectModerationEventDialogStyles` function is well-structured and follows the existing pattern in the file of using `Object.freeze()` to create immutable style objects. The styles are appropriately named and organized for the dialog components.

</details>
<details>
<summary>src/react/src/__tests__/components/RejectModerationEventDialog.test.js (1)</summary>

`1-144`: **Comprehensive test coverage with good mocking strategy**

The test suite covers all important behaviors of the component including rendering, button interactions, validation logic, and tooltip functionality. The mock implementations for `react-draft-wysiwyg` and Material UI's `Tooltip` are well-designed to enable proper testing.

</details>
<details>
<summary>src/react/src/components/Dashboard/RejectModerationEventDialog.jsx (3)</summary>

`79-85`: **LGTM - Good user guidance in the dialog**

The instructions for the user are clear and provide helpful direction about keeping feedback action-oriented, which promotes constructive communication.

---

`94-102`: **Well-configured toolbar options**

The toolbar configuration provides just the necessary formatting options without overwhelming the user with too many choices. This is appropriate for this specific use case.

---

`115-121`: **Clear user feedback with tooltip**

The tooltip provides clear feedback to users about the minimum character requirement, which improves the user experience.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T14:34:39Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:37:52Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:40:02Z
user: login: protsack-stephan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T14:53:14Z
user: login: protsack-stephan
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-06T14:53:20Z
user: login: VadimKovalenkoSNF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:00:27Z
user: login: vladsha-dev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:03:09Z
user: login: vladsha-dev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:08:50Z
user: login: VadimKovalenkoSNF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:10:24Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:18:37Z
user: login: VadimKovalenkoSNF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:20:10Z
user: login: vladsha-dev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:22:10Z
user: login: mazursasha1990
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:43:59Z
user: login: VadimKovalenkoSNF
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:59:06Z
user: login: VadimKovalenkoSNF
body: Approved, good work.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T04:20:13Z

Review comments:
diff_hunk: @@ -22,7 +22,7 @@
           <b>Review the feedback below:</b>
         </p>
         <ul>
-          <li>{{ action_reason_text_raw }}</li>
+          <li>{{ action_reason_text_raw | safe }}</li>
path: src/django/api/templates/mail/slc_contribution_rejected_body.html
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Security risk: Using the `safe` filter could allow XSS attacks.**

The `safe` filter bypasses Django's automatic HTML escaping, which means any HTML tags in `action_reason_text_raw` will be rendered as actual HTML rather than displayed as text. This introduces a potential Cross-Site Scripting (XSS) vulnerability if the content is not properly sanitized before storage.


Ensure that the `action_reason_text_raw` content is properly sanitized on the server side before it's stored in the database. Consider one of these approaches:

1. Use Django's `bleach` library to sanitize HTML:
```diff
- <li>{{ action_reason_text_raw | safe }}</li>
+ <li>{{ action_reason_text_raw | bleach }}</li>
```

2. If HTML formatting is not necessary, remove the `safe` filter:
```diff
- <li>{{ action_reason_text_raw | safe }}</li>
+ <li>{{ action_reason_text_raw }}</li>
```

3. If you must keep the `safe` filter, ensure proper sanitization is happening in the backend when storing the value.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if HTML sanitization is implemented for action_reason_text_raw

# Look for sanitization in views or serializers
echo "Checking for HTML sanitization..."
rg -A 10 -B 10 "action_reason_text_raw" --type py | grep -i "sanitiz\|clean\|bleach\|purif\|xss"

# Check if Django-Bleach is installed
echo "Checking for Django-Bleach..."
pip freeze | grep bleach
```

Length of output: 3167

---



</details>

**Security Concern in Email Template – Address XSS Risk**

The template at `src/django/api/templates/mail/slc_contribution_rejected_body.html` (line 25) currently renders:
```html
<li>{{ action_reason_text_raw | safe }}</li>
```
Using the `safe` filter bypasses Django’s escaping and directly renders potential HTML from `action_reason_text_raw`. Our repository search shows that a sanitized value (`action_reason_text_cleaned`) is maintained (e.g., in `src/django/api/mail.py` and serializers), yet this template uses the unsanitized raw input. Additionally, the Django-Bleach package does not appear to be installed per the `pip freeze` output.

Please address this security risk by doing one of the following:
- **Preferred:** Update the template to use the pre-sanitized field:
  ```diff
  - <li>{{ action_reason_text_raw | safe }}</li>
  + <li>{{ action_reason_text_cleaned }}</li>
  ```
- **Alternatively:** If HTML formatting is necessary and you intend to use the raw value, remove the `safe` filter and ensure that sanitization is applied on the server side (e.g., by installing and using a library like Django-Bleach), after which you can safely render the cleaned content.

Ensure the chosen approach mitigates any XSS vulnerabilities.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-04T19:30:39Z
author_association: NONE
diff_hunk: @@ -147,6 +147,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
+        blank=True,
+        help_text='Cleaned version of the action reason text.'
+    )
+
+    action_reason_text_raw = models.TextField(
+        blank=True,
+        help_text='Raw version of the action reason text.'
+    )
path: src/django/api/models/moderation_event.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Add validation for maximum length and consider security implications of storing raw HTML.**

Given that these fields will store rejection messages, including potentially raw HTML, consider:
1. Adding a maximum length constraint to prevent abuse
2. Implementing server-side validation to sanitize HTML content before storage 
3. Documenting the security implications of storing raw HTML

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if there's any validation or sanitization for the text fields

# Look for validation in serializers
echo "Checking for validation in serializers..."
rg -A 10 "ModerationEventUpdateSerializer" --type py

# Look for HTML sanitization implementation
echo "Checking for HTML sanitization..."
rg -A 5 -B 5 "action_reason_text_raw" --type py | grep -i "sanitiz\|clean\|bleach\|purif\|xss"
```

Length of output: 5869

---



</details>

**Action Required: Add Maximum Length Constraint and HTML Sanitization**

The current implementation in the serializer enforces a minimum length (MIN_REASON_TEXT_LENGTH) for rejection messages, but there is no maximum length validation for either the cleaned or raw text field. Additionally, no server-side sanitization for raw HTML is applied, potentially exposing the application to XSS or other security issues.

Recommendations:
- **Validation Constraints:** Add a maximum length constraint for both `action_reason_text_cleaned` and `action_reason_text_raw` fields in the serializer and/or model to prevent abuse.
- **HTML Sanitization:** Implement server-side sanitization (e.g., using a library like Bleach) for the `action_reason_text_raw` field before storing any HTML content.
- **Documentation:** Clearly document the security considerations and mitigation strategies regarding the storage of raw HTML in this context.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-04T19:30:39Z
author_association: NONE
diff_hunk: @@ -130,15 +130,24 @@ export function fetchPotentialMatches(data) {
     };
 }
 
-export function updateSingleModerationEvent(moderationID, status) {
+export function updateSingleModerationEvent(
+    moderationID,
+    status,
+    textCleaned,
+    textRaw,
+) {
     return dispatch => {
         if (!moderationID) {
             return null;
         }
         dispatch(startUpdateSingleModerationEvent());
 
         return apiRequest
-            .patch(makeModerationEventRecordURL(moderationID), { status })
+            .patch(makeModerationEventRecordURL(moderationID), {
+                status,
+                action_reason_text_cleaned: textCleaned,
+                action_reason_text_raw: textRaw,
+            })
             .then(({ data }) => {
path: src/react/src/actions/dashboardContributionRecord.js
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Function parameter and API request updates look good.**

The function signature now includes parameters for the text fields, and the API request properly includes these new parameters.


Let's make sure all call sites to this function have been updated to include the new parameters:

---

🏁 Script executed:

```shell
#!/bin/bash
# Find all places where updateSingleModerationEvent is called

echo "Checking for calls to updateSingleModerationEvent:"
rg -A 2 "updateSingleModerationEvent\(" --type js
```

Length of output: 695

---



</details>

**Attention: Update Call Sites for New Text Parameters**

The changes in the function signature and API request within `src/react/src/actions/dashboardContributionRecord.js` look good. However, our investigation found that the call site in `src/react/src/components/Dashboard/DashboardContributionRecord.jsx` is still only supplying `moderationID` and `status`. Please update this call (and any other similar invocations) to include the new `textCleaned` and `textRaw` parameters. For example, modify it as follows:

```js
updateSingleModerationEvent(
  moderationID,
  status,
  textCleaned, // pass appropriate value
  textRaw      // pass appropriate value
)
```

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-04T19:30:40Z
author_association: NONE
diff_hunk: @@ -130,15 +130,24 @@ export function fetchPotentialMatches(data) {
     };
 }
 
-export function updateSingleModerationEvent(moderationID, status) {
+export function updateSingleModerationEvent(
+    moderationID,
+    status,
+    textCleaned,
+    textRaw,
+) {
     return dispatch => {
         if (!moderationID) {
             return null;
         }
         dispatch(startUpdateSingleModerationEvent());
 
         return apiRequest
-            .patch(makeModerationEventRecordURL(moderationID), { status })
+            .patch(makeModerationEventRecordURL(moderationID), {
+                status,
+                action_reason_text_cleaned: textCleaned,
+                action_reason_text_raw: textRaw,
+            })
             .then(({ data }) => {
path: src/react/src/actions/dashboardContributionRecord.js
user: login: mazursasha1990
body: I don't see the described issue.
created_at: 2025-03-05T12:22:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,15 +130,24 @@ export function fetchPotentialMatches(data) {
     };
 }
 
-export function updateSingleModerationEvent(moderationID, status) {
+export function updateSingleModerationEvent(
+    moderationID,
+    status,
+    textCleaned,
+    textRaw,
+) {
     return dispatch => {
         if (!moderationID) {
             return null;
         }
         dispatch(startUpdateSingleModerationEvent());
 
         return apiRequest
-            .patch(makeModerationEventRecordURL(moderationID), { status })
+            .patch(makeModerationEventRecordURL(moderationID), {
+                status,
+                action_reason_text_cleaned: textCleaned,
+                action_reason_text_raw: textRaw,
+            })
             .then(({ data }) => {
path: src/react/src/actions/dashboardContributionRecord.js
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

Let me investigate this issue further to understand if there's a discrepancy between the function signature update and its usage.




Based on the investigation, you're absolutely right - there is no issue with the call sites. The `mapDispatchToProps` in `DashboardContributionRecord.jsx` has been properly updated to pass all required parameters to `updateSingleModerationEvent`, and the dialog component correctly handles the text parameters.

@mazursasha1990 I apologize for the confusion in my previous comment. You're correct - there is no issue with the call sites. The PR correctly implements the function signature changes and all invocations of the function have been properly updated to include the new parameters. My initial analysis was incorrect. 

The changes you've made look good - the function signature includes the new text parameters, and all the call sites have been appropriately modified to support these parameters.

---

🏁 Script executed:
```
#!/bin/bash
# Check how updateModerationEvent is mapped in DashboardContributionRecord.jsx
echo "=== Checking mapDispatchToProps in DashboardContributionRecord.jsx ==="
rg "mapDispatchToProps" src/react/src/components/Dashboard/DashboardContributionRecord.jsx -A 15 -B 5

echo -e "\n=== Checking updateModerationEvent usage in DashboardContributionRecord.jsx ==="
rg "updateModerationEvent" src/react/src/components/Dashboard/DashboardContributionRecord.jsx

echo -e "\n=== Checking how updateModerationEvent is used in ConfirmActionDialog.jsx ==="
rg "updateModerationEvent" src/react/src/components/Dashboard/ConfirmActionDialog.jsx -A 5 -B 5
```

Length of output: 2562

---



</details>

@mazursasha1990 You're absolutely right, and I apologize for the confusion in my previous comment. After checking the code more thoroughly, I can see that there is no issue with call sites.

The implementation in `DashboardContributionRecord.jsx` correctly maps the `updateModerationEvent` prop with all three parameters:
```javascript
updateModerationEvent: (status, textCleaned, textRaw) =>
    dispatch(updateSingleModerationEvent(moderationID, status, textCleaned, textRaw))
```

And in `ConfirmActionDialog.jsx`, you're properly using all three parameters:
```javascript
updateModerationEvent(
    MODERATION_STATUSES_ENUM.REJECTED,
    cleanedText,
    htmlContent
);
```

Your implementation is correct. Thank you for pointing this out - the issue I mentioned does not exist.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-05T12:23:23Z
author_association: NONE
diff_hunk: @@ -147,6 +147,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
+        blank=True,
+        help_text='Cleaned version of the action reason text.'
+    )
+
+    action_reason_text_raw = models.TextField(
+        blank=True,
+        help_text='Raw version of the action reason text.'
+    )
path: src/django/api/models/moderation_event.py
user: login: mazursasha1990
body: I don't want to add a maximum length constraint for the `action_reason_text_cleaned` and `action_reason_text_raw` fields.
created_at: 2025-03-05T14:03:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -147,6 +147,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
+        blank=True,
+        help_text='Cleaned version of the action reason text.'
+    )
+
+    action_reason_text_raw = models.TextField(
+        blank=True,
+        help_text='Raw version of the action reason text.'
+    )
path: src/django/api/models/moderation_event.py
user: login: coderabbitai[bot]
body: @mazursasha1990 Understood! I'll respect your decision not to add maximum length constraints to these fields. The TextField in Django doesn't impose a maximum length by default, which gives you flexibility for storing longer rejection messages.

While you've decided against length constraints, I'd still recommend considering server-side HTML sanitization for the `action_reason_text_raw` field since it appears to store raw HTML content (as indicated by the `safe` filter usage in the email template). This would help prevent potential XSS vulnerabilities if malicious content is submitted.

A simple approach could be using a library like `bleach` to sanitize the HTML before storing it, without restricting its length:

```python
import bleach

# In your serializer's validate or save method
cleaned_html = bleach.clean(
    raw_html,
    tags=['p', 'b', 'i', 'u', 'ol', 'ul', 'li', 'br'],
    strip=True
)
```

Let me know if you'd like more information about this approach or if you have any other questions!

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-05T14:03:35Z
author_association: NONE
diff_hunk: @@ -22,7 +22,7 @@
           <b>Review the feedback below:</b>
         </p>
         <ul>
-          <li>{{ action_reason_text_raw }}</li>
+          <li>{{ action_reason_text_raw | safe }}</li>
path: src/django/api/templates/mail/slc_contribution_rejected_body.html
user: login: mazursasha1990
body: Applied Django-Bleach library.
created_at: 2025-03-06T12:06:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,7 +22,7 @@
           <b>Review the feedback below:</b>
         </p>
         <ul>
-          <li>{{ action_reason_text_raw }}</li>
+          <li>{{ action_reason_text_raw | safe }}</li>
path: src/django/api/templates/mail/slc_contribution_rejected_body.html
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

Thank you for the information. I need to check if the Django-Bleach library has been properly applied to the template.

---

🏁 Script executed:
```
#!/bin/bash
# Check if the template has been updated to use the bleach filter
cat src/django/api/templates/mail/slc_contribution_rejected_body.html | grep -A 2 "Review the feedback below"

# Check if Django-Bleach is installed and configured
pip freeze | grep -i bleach
grep -r "BLEACH_ALLOWED_TAGS" src/django/
```

Length of output: 335

---



</details>

@mazursasha1990 Great! I've verified that you've successfully implemented Django-Bleach:

1. The template now uses `{{ action_reason_text_raw|bleach }}` instead of the unsafe `safe` filter
2. The `BLEACH_ALLOWED_TAGS` configuration is properly set up in the Django settings file

This change effectively addresses the XSS vulnerability by sanitizing HTML content before rendering it in the email template. The bleach filter will strip out any potentially dangerous tags while preserving the allowed formatting.

Nice work on implementing this security improvement! 



             (\(\
            (>'-')
           o(__")")

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%F0%9F%9A%A8%20Just%20received%20a%20crucial%20review%20from%20%40coderabbitai!%20They%20highlighted%20a%20potential%20XSS%20risk%20in%20our%20email%20template.%20Always%20sanitize%20inputs!%20Let's%20keep%20our%20apps%20secure!%20%F0%9F%94%92&url=https%3A%2F%2Fgithub.com%2Fopensupplyhub%2Fopen-supply-hub%2Fpull%2F538%23discussion_r1980110205)?
							Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-03-06T12:08:02Z
author_association: NONE
diff_hunk: @@ -78,12 +84,58 @@ def __validate_status(self, value):
             })
         return value
 
+    def validate(self, data):
+        status = data.get('status')
+        if status == ModerationEvent.Status.REJECTED:
path: src/django/api/serializers/v1/moderation_event_update_serializer.py
user: login: protsack-stephan
body: Can we change this to be a reverse if statement something like:

```python
if  data.get('status') != ModerationEvent.Status.REJECTED:
  return data
```
created_at: 2025-03-06T13:20:21Z
author_association: COLLABORATOR
diff_hunk: @@ -21,9 +22,7 @@
         <p>
           <b>Review the feedback below:</b>
         </p>
-        <ul>
-          <li>{{ action_reason_text_raw }}</li>
-        </ul>
+        {{ action_reason_text_raw|bleach }}
path: src/django/api/templates/mail/slc_contribution_rejected_body.html
user: login: protsack-stephan
body: Should this be indented like this if possible? Just makes it a bit more readable.

```
{{ action_reason_text_raw | bleach }}
```
created_at: 2025-03-06T13:22:45Z
author_association: COLLABORATOR
diff_hunk: @@ -147,6 +148,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
path: src/django/api/models/moderation_event.py
user: login: VadimKovalenkoSNF
body: I suggest to add `max_length` attribute for fields introduced in this PR.
created_at: 2025-03-06T13:26:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: VadimKovalenkoSNF
body: Pls, add here similar text that you've added in Migrations section.
created_at: 2025-03-06T13:31:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,9 +22,7 @@
         <p>
           <b>Review the feedback below:</b>
         </p>
-        <ul>
-          <li>{{ action_reason_text_raw }}</li>
-        </ul>
+        {{ action_reason_text_raw|bleach }}
path: src/django/api/templates/mail/slc_contribution_rejected_body.html
user: login: mazursasha1990
body: Done.
created_at: 2025-03-06T13:35:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import React, { useState, useMemo } from 'react';
path: src/react/src/components/Dashboard/ConfirmActionDialog.jsx
user: login: VadimKovalenkoSNF
body: Maybe it is better to rename this component to `RejectActionDialog.jsx` or `RejectModerationEventDialog.jsx`?
created_at: 2025-03-06T13:35:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,12 +84,58 @@ def __validate_status(self, value):
             })
         return value
 
+    def validate(self, data):
+        status = data.get('status')
+        if status == ModerationEvent.Status.REJECTED:
path: src/django/api/serializers/v1/moderation_event_update_serializer.py
user: login: mazursasha1990
body: Done.
created_at: 2025-03-06T13:54:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -147,6 +148,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
path: src/django/api/models/moderation_event.py
user: login: mazursasha1990
body: The `TextField is` designed to hold large amounts of text and doesn’t enforce a maximum length at the database level.
https://docs.djangoproject.com/en/5.1/ref/models/fields/#textfield

So, I believe it's not necessary to add a `max_length` attribute for these fields.
created_at: 2025-03-06T14:08:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import React, { useState, useMemo } from 'react';
path: src/react/src/components/Dashboard/ConfirmActionDialog.jsx
user: login: mazursasha1990
body: Initially, I thought that this component would be common for all moderation event actions, therefore I called it `ConfirmActionDialog`.
But in the current stage, I agree that it would be better to rename it because it's unknown whether we will implement such a dialog window for other actions or not.

Renamed to the `RejectModerationEventDialog`.
Thank you.

created_at: 2025-03-06T14:37:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: mazursasha1990
body: I'm not sure it was designed for these purposes.
@vladsha-dev Could you clarify, a little bit?
created_at: 2025-03-06T14:40:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -78,12 +84,61 @@ def __validate_status(self, value):
             })
         return value
 
+    def validate(self, data):
+        status = data.get('status')
+
+        if status != ModerationEvent.Status.REJECTED:
+            return data
+
+        cleaned = data.get('action_reason_text_cleaned')
+        raw = data.get('action_reason_text_raw')
+        errors = []
+
+        if not cleaned:
+            errors.append({
+                "field": "action_reason_text_cleaned",
+                "detail": (
+                    "This field is required when rejecting a moderation "
+                    "event."
+                )
+            })
+        elif len(cleaned) < self.MIN_REASON_TEXT_LENGTH:
+            errors.append({
+                "field": "action_reason_text_cleaned",
+                "detail": "This field must be at least 30 characters."
+            })
+
+        if not raw:
+            errors.append({
+                "field": "action_reason_text_raw",
+                "detail": (
+                    "This field is required when rejecting a moderation "
+                    "event."
+                )
+            })
+        elif len(raw) < self.MIN_REASON_TEXT_LENGTH:
+            errors.append({
+                "field": "action_reason_text_raw",
+                "detail": "This field must be at least 30 characters."
+            })
path: src/django/api/serializers/v1/moderation_event_update_serializer.py
user: login: protsack-stephan
body: This is just a comment for the future, no need to change it in this PR. I would suggest assigning the variable right before using it instead of at the top of the file, that means assigning the `raw` value right before this if else statement, like this:

```python
raw = data.get('action_reason_text_raw')

if not raw:
            errors.append({
                "field": "action_reason_text_raw",
                "detail": (
                    "This field is required when rejecting a moderation "
                    "event."
                )
            })
        elif len(raw) < self.MIN_REASON_TEXT_LENGTH:
            errors.append({
                "field": "action_reason_text_raw",
                "detail": "This field must be at least 30 characters."
            })
```
created_at: 2025-03-06T14:53:14Z
author_association: COLLABORATOR
diff_hunk: @@ -147,6 +148,16 @@ class ActionType(models.TextChoices):
         )
     )
 
+    action_reason_text_cleaned = models.TextField(
path: src/django/api/models/moderation_event.py
user: login: VadimKovalenkoSNF
body: Ack.
created_at: 2025-03-06T15:00:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: vladsha-dev
body: > Pls, add here similar text 

@VadimKovalenkoSNF, this section under the "Database Changes" title was added to include information that is not related to migrations and the DB schema, such as the Postgres version upgrade—anything beyond just migrations and DB schema. So, I wouldn’t add this text here either.
created_at: 2025-03-06T15:03:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: vladsha-dev
body: Since almost all the work around the DB is related to schema and migrations, everything else would definitely be more general and high-level. @VadimKovalenkoSNF, what do you think?
created_at: 2025-03-06T15:08:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: VadimKovalenkoSNF
body: Then I suggest to remove `* *Describe high-level database changes.*` text and combine sections like this. We did like that in some of previous release notes (see release 1.23.0). 

```
### Database changes
#### Migrations:
<you migrations go there>
```
created_at: 2025-03-06T16:10:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: mazursasha1990
body: I believe it is worth clearing the release notes in the last PR before the Release (or creating a special PR for these purposes).
created_at: 2025-03-06T16:18:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: VadimKovalenkoSNF
body: Makes sense, but this will require additional negotiations between devs to decide who is last.
created_at: 2025-03-06T16:20:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: vladsha-dev
body: For this release, yes, it can be removed. I would just keep it in the release notes template file.
created_at: 2025-03-06T16:22:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: mazursasha1990
body: > Makes sense, but this will require additional negotiations between devs to decide who is last.

It could be a responsible person for the Release.
I think we can agree it on one of our tech calls.
created_at: 2025-03-06T16:43:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,13 +13,14 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
 * *Describe high-level database changes.*
path: doc/release/RELEASE-NOTES.md
user: login: VadimKovalenkoSNF
body: I mark this conversation as resolved.
created_at: 2025-03-06T16:59:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: barecheck[bot]
created_at: 2025-02-26T20:51:11Z
body: <h3>React App | Jest test suite - Code coverage report</h3>Total: <b>32.61%</b>

Your code coverage diff: <b>0.25% ▴</b>

✅ All code changes are covered
author_association: NONE
user: login: barecheck[bot]
created_at: 2025-02-26T20:55:27Z
body: <h3>Dedupe Hub App | Unittest test suite - Code coverage report</h3>Total: <b>56.14%</b>

Your code coverage diff: <b>0.00% ▴</b>

✅ All code changes are covered
author_association: NONE
user: login: barecheck[bot]
created_at: 2025-02-26T20:56:01Z
body: <h3>Countries App | Unittest test suite - Code coverage report</h3>Total: <b>100%</b>

Your code coverage diff: <b>0.00% ▴</b>

✅ All code changes are covered
author_association: NONE
user: login: barecheck[bot]
created_at: 2025-02-26T20:56:18Z
body: <h3>Contricleaner App | Unittest test suite - Code coverage report</h3>Total: <b>98.91%</b>

Your code coverage diff: <b>0.00% ▴</b>

✅ All code changes are covered
author_association: NONE
user: login: barecheck[bot]
created_at: 2025-02-26T21:05:52Z
body: <h3>Django App | Unittest test suite - Code coverage report</h3>Total: <b>80.61%</b>

Your code coverage diff: <b>0.06% ▴</b>

✅ All code changes are covered
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-03-04T19:30:36Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough
This pull request introduces two new fields—`action_reason_text_cleaned` and `action_reason_text_raw`—to the moderation event model in the Django backend. The changes extend serializer validation, update view error handling, and add corresponding tests. On the frontend, the update process now supports additional parameters for handling moderation event rejections through a new dialog component with rich text editing capabilities. Additional configuration for Django Bleach is also included to safely sanitize HTML input. Documentation and package dependencies have been updated accordingly.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `src/django/api/migrations/0167...fields.py`, `src/django/api/models/moderation_event.py` | Added fields `action_reason_text_cleaned` (TextField) and `action_reason_text_raw` (BleachField) to the ModerationEvent model. |
| `src/django/api/serializers/v1/moderation_event_update_serializer.py`, `src/django/api/views/v1/moderation_events.py` | Updated the serializer to include new write-only fields with minimum length validation for REJECTED events and modified view error handling to retrieve `non_field_errors`. |
| `src/django/api/tests/test_moderation_events_update.py` | Renamed and added tests to validate moderation event rejections with valid, invalid, and missing reason texts, and disconnected an OpenSearch signal. |
| `src/django/api/templates/mail/slc_contribution_rejected_body.html` | Revised the email template to use the `bleach` filter for rendering `action_reason_text_raw` directly. |
| `src/django/oar/settings.py`, `src/django/requirements.txt` | Added Django Bleach configuration settings and introduced the `django-bleach==2.0.0` dependency for HTML sanitization. |
| `doc/release/RELEASE-NOTES.md` | Updated release notes to document the new migration, schema changes for moderation events, and enhanced API validations. |
| `src/react/package.json` | Added new dependencies: `draft-js`, `draftjs-to-html`, and `react-draft-wysiwyg` for rich text editing. |
| `src/react/src/actions/dashboardContributionRecord.js`, `src/react/src/components/Dashboard/DashboardContributionRecord.jsx` | Modified the update function and component to pass additional parameters (`textCleaned` and `textRaw`) and integrated new dialog handlers for moderation event rejection. |
| `src/react/src/__tests__/components/DashboardContributionRecord.test.js`, `src/react/src/__tests__/components/RejectModerationEventDialog.test.js` | Added tests to verify the open/close behavior of the rejection dialog and ensure proper user interaction flows. |
| `src/react/src/components/Dashboard/RejectModerationEventDialog.jsx` | Introduced a new dialog component implementing a rich text editor with input length validation and tooltip feedback for rejecting moderation events. |
| `src/react/src/util/styles.js` | Added `makeRejectModerationEventDialogStyles` to provide immutable style definitions for the rejection dialog. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Dashboard as DashboardContributionRecord
    participant Dialog as RejectModerationEventDialog
    participant Action as updateSingleModerationEvent
    participant API as ModerationEvents API
    participant Serializer as ModerationEventUpdateSerializer

    User->>Dashboard: Click "Reject Contribution"
    Dashboard->>Dialog: Open rejection dialog
    User->>Dialog: Enter rejection message (raw & cleaned text)
    Dialog->>Action: Submit rejection (status, textCleaned, textRaw)
    Action->>API: Send PATCH request for moderation event update
    API->>Serializer: Validate request (min length check)
    Serializer-->>API: Return success or validation errors
    API-->>Action: Send response
    Action-->>Dashboard: Update event status accordingly
```

## Possibly related PRs
- **opensupplyhub/open-supply-hub#393**: The changes in the main PR are related to the modifications in the retrieved PR, as both involve the `ModerationEventUpdateSerializer` class and the addition of new fields for handling moderation event updates.
- **opensupplyhub/open-supply-hub#504**: The changes in the main PR, which add new fields to the `moderationevent` model, are related to the retrieved PR that also modifies the `moderationevent` model by adding a new field for tracking the user who performed moderation actions.
- **opensupplyhub/open-supply-hub#397**: The changes in the main PR, which involve adding new fields to the `moderationevent` model and updating related serializers, are directly related to the modifications in the retrieved PR, where new action creators and components for managing moderation events are introduced, including the handling of the same fields.

## Suggested reviewers
- **Innavin369**
- **vladsha-dev**
- **roman-stolar**
- **VadimKovalenkoSNF**

</details>

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between fab1b136df708f0488ca07ea6d39808bf29b483b and a64a586d9e2ee6204aeeb5f0cd077509cdef25f5.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `doc/release/RELEASE-NOTES.md` (2 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* doc/release/RELEASE-NOTES.md

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (13)</summary>

* GitHub Check: run-integration-test-code-quality
* GitHub Check: run-flake8-linter
* GitHub Check: run-fe-code-quality
* GitHub Check: run-eslint-linter-and-prettier-formatter
* GitHub Check: run-django-code-quality
* GitHub Check: run-dd-code-quality
* GitHub Check: run-countries-code-quality
* GitHub Check: run-contricleaner-code-quality
* GitHub Check: get-base-branch-fe-cov
* GitHub Check: get-base-branch-countries-cov
* GitHub Check: get-base-branch-dd-cov
* GitHub Check: get-base-branch-contricleaner-cov
* GitHub Check: get-base-branch-django-cov

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=opensupplyhub/open-supply-hub&utm_content=538):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-06T17:07:07Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=opensupplyhub_open-supply-hub&pullRequest=538) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=opensupplyhub_open-supply-hub&pullRequest=538&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [9 Accepted issues](https://sonarcloud.io/project/issues?id=opensupplyhub_open-supply-hub&pullRequest=538&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=opensupplyhub_open-supply-hub&pullRequest=538&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=opensupplyhub_open-supply-hub&pullRequest=538&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=opensupplyhub_open-supply-hub&pullRequest=538&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=opensupplyhub_open-supply-hub&pullRequest=538)


author_association: NONE