Repository: django/django

Issue:
# Fixed #36841 -- Made multipart parser class pluggable on HttpRequest.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36841

#### Branch description
Provide a concise overview of the issue or rationale behind the proposed changes.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/http/request.py b/django/http/request.py
index c8adde768d23..573ae2b229d6 100644
--- a/django/http/request.py
+++ b/django/http/request.py
@@ -56,6 +56,7 @@ class HttpRequest:
     # The encoding used in GET/POST dicts. None means use default setting.
     _encoding = None
     _upload_handlers = []
+    _multipart_parser_class = MultiPartParser
 
     def __init__(self):
         # WARNING: The `WSGIRequest` subclass doesn't call `super`.
@@ -364,6 +365,19 @@ def upload_handlers(self, upload_handlers):
             )
         self._upload_handlers = upload_handlers
 
+    @property
+    def multipart_parser_class(self):
+        return self._multipart_parser_class
+
+    @multipart_parser_class.setter
+    def multipart_parser_class(self, multipart_parser_class):
+        if hasattr(self, "_files"):
+            raise RuntimeError(
+                "You cannot set the multipart parser class after the upload has been "
+                "processed."
+            )
+        self._multipart_parser_class = multipart_parser_class
+
     def parse_file_upload(self, META, post_data):
         """Return a tuple of (POST QueryDict, FILES MultiValueDict)."""
         self.upload_handlers = ImmutableList(
@@ -373,7 +387,9 @@ def parse_file_upload(self, META, post_data):
                 "processed."
             ),
         )
-        parser = MultiPartParser(META, post_data, self.upload_handlers, self.encoding)
+        parser = self.multipart_parser_class(
+            META, post_data, self.upload_handlers, self.encoding
+        )
         return parser.parse()
 
     @property


Test patch:
diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py
index 36843df9b65b..e52989b0da78 100644
--- a/tests/requests_tests/tests.py
+++ b/tests/requests_tests/tests.py
@@ -13,7 +13,11 @@
     RawPostDataException,
     UnreadablePostError,
 )
-from django.http.multipartparser import MAX_TOTAL_HEADER_SIZE, MultiPartParserError
+from django.http.multipartparser import (
+    MAX_TOTAL_HEADER_SIZE,
+    MultiPartParser,
+    MultiPartParserError,
+)
 from django.http.request import split_domain_port
 from django.test import RequestFactory, SimpleTestCase, override_settings
 from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload
@@ -1112,6 +1116,72 @@ def test_deepcopy(self):
         request.session["key"] = "value"
         self.assertEqual(request_copy.session, {})
 
+    def test_custom_multipart_parser_class(self):
+
+        class CustomMultiPartParser(MultiPartParser):
+            def parse(self):
+                post, files = super().parse()
+                post._mutable = True
+                post["custom_parser_used"] = "yes"
+                post._mutable = False
+                return post, files
+
+        class CustomWSGIRequest(WSGIRequest):
+            multipart_parser_class = CustomMultiPartParser
+
+        payload = FakePayload(
+            "\r\n".join(
+                [
+                    "--boundary",
+                    'Content-Disposition: form-data; name="name"',
+                    "",
+                    "value",
+                    "--boundary--",
+                ]
+            )
+        )
+        request = CustomWSGIRequest(
+            {
+                "REQUEST_METHOD": "POST",
+                "CONTENT_TYPE": "multipart/form-data; boundary=boundary",
+                "CONTENT_LENGTH": len(payload),
+                "wsgi.input": payload,
+            }
+        )
+        self.assertEqual(request.POST.get("custom_parser_used"), "yes")
+        self.assertEqual(request.POST.get("name"), "value")
+
+    def test_multipart_parser_class_immutable_after_parse(self):
+        payload = FakePayload(
+            "\r\n".join(
+                [
+                    "--boundary",
+                    'Content-Disposition: form-data; name="name"',
+                    "",
+                    "value",
+                    "--boundary--",
+                ]
+            )
+        )
+        request = WSGIRequest(
+            {
+                "REQUEST_METHOD": "POST",
+                "CONTENT_TYPE": "multipart/form-data; boundary=boundary",
+                "CONTENT_LENGTH": len(payload),
+                "wsgi.input": payload,
+            }
+        )
+
+        # Access POST to trigger parsing.
+        request.POST
+
+        msg = (
+            "You cannot set the multipart parser class after the upload has been "
+            "processed."
+        )
+        with self.assertRaisesMessage(RuntimeError, msg):
+            request.multipart_parser_class = MultiPartParser
+
 
 class HostValidationTests(SimpleTestCase):
     poisoned_hosts = [