I had a similar case and found that, using boolean logic to put the negations on the leaves of the tree solved the issue.
I made a Django snippet here :
https://djangosnippets.org/snippets/10866/
Here is a copy of my code :
def put_Q_negations_to_leaves(
    query_filter: Q,
    negate: bool = False,
    first_call: bool = True,
    debug: bool = False,
):
    negate_below = (negate != query_filter.negated)  # XOR
    if debug:
        logger.info(
            f"put_Q_negations_to_leaves() query_filter:{query_filter}"
            f" negate:{negate} negate_below:{negate_below}"
        )
    true_kwargs = {
        "_connector": query_filter.connector,
        "_negated": False,
    }
    new_children = []
    for child in query_filter.children:
        if debug:
            logger.info(child.__repr__())
        if not isinstance(child, Q):
            if negate_below:
                new_child = ~Q(child)
            else:
                new_child = child
        else:
            new_child = put_Q_negations_to_leaves(child, negate=negate_below, first_call=False)
        if debug:
            logger.info(new_child.__repr__())
        new_children.append(new_child)
    if len(new_children) == 1:
        # One child
        if isinstance(new_children[0], Q) or first_call == False:
            # Double negation canceled out if possible
            return new_children[0]
        else:
            true_kwargs["_negated"] = negate_below
    if negate_below:
        if true_kwargs["_connector"] == Q.AND:
            true_kwargs["_connector"] = Q.OR
        else:
            true_kwargs["_connector"] = Q.AND
    return Q(*new_children, **true_kwargs)
To make this snippet works in all cases, it is necessary to change the following lines :
if negate_below:
    new_child = ~Q(child)
You must handle all negation of field lookups :
https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups-1
with string manipulation on the first element of the tuple.
For that, you can look at this answer on StackOverflow : How do I do a not equal in Django queryset filtering? https://stackoverflow.com/a/29227603/5796086
However, for most uses, it will be simpler to use a SubQuery (or Exists).
Use example :
from django.db.models import Q, F
# For simplicity, and avoiding mixing args and kwargs, we only use args since :
# ("some_fk__some_other_fk__some_field", 111) arg
# is equivalent to
# some_fk__some_other_fk__some_field=111 kwarg
unmodified_filter = ~Q(
  ("some_fk__some_other_fk__some_field", 111),
  Q(("some_fk__some_other_fk__some_other_field__lt", 11))
  | ~Q(("some_fk__some_other_fk__some_yet_another_field", F("some_fk__some_yet_another_field")))
)
modified_filter = put_Q_negations_to_leaves(unmodified_filter)
print(unmodified_filter)
print(modified_filter)
This will output something that you can beautify like this:
Before:
(NOT
   (AND:
      ('some_fk__some_other_fk__some_field', 111),
      (OR:
         ('some_fk__some_other_fk__some_other_field__lt', 11),
         (NOT
            (AND: ('some_fk__some_other_fk__some_yet_another_field', F(some_fk__some_yet_another_field)))
         )
      )
   )
)
After:
(OR:
   (NOT
      (AND: ('some_fk__some_other_fk__some_field', 111))
   ),
   (AND:
      (NOT
         (AND: ('some_fk__some_other_fk__some_other_field__lt', 11))
      ), <-- This is where negation of lookups like "lt" -> "gte" should be handled
      ('some_fk__some_other_fk__some_yet_another_field', F(some_fk__some_yet_another_field))  <-- Double negation canceled out
   )
)