<commit_before>from django.db import models
from django.db.models.query import QuerySet
from django.db.models.query_utils import Q
from django.utils.timezone import now
from fluent_pages.models.managers import UrlNodeQuerySet, UrlNodeManager
from model_utils.managers import PassThroughManagerMixin

from .middleware import is_draft_request_context, \
    is_publishing_middleware_active
from .utils import PublishingException


UNSET = object()  # Constant object to mark unset kwargs


class DraftItemBoobyTrap(object):
    """
    Wrapper for draft publishable items intended to prevent any misuse of
    draft items by accessing their data while in a published-only request
    context.

    This class will pass through a shortlist of safe attrs/methods to the
    wrapped payload object, just enough so the publishing status can be checked
    or the actual published copy can be obtained by downstream code.

    For any other attr/method access attempts, which most likely are the result
    of treating a draft item as if it were published, this class will raise an
    exception explaining why the access was illegal and recommend steps for
    devs to fix the problem.

    The list of permitted attr/method names can be extended from the values
    in ``DEFAULT_PERMITTED_ATTRS`` by specifying the list attribute
    ``PUBLISHING_DRAFT_PERMITTED_ATTRS`` on the draft object or class.
    """
    # Attribute or method names that are safe to access, for checking on draft
    # status or exchanging a draft payload for the published copy.
    DEFAULT_PERMITTED_ATTRS = [
        'get_draft_payload',
        'get_published',
        'get_visible',
        # NOTE: `get_draft` is not included here to discourage getting a draft
        'publishing_linked',
        'publishing_linked_id',
        'publishing_is_draft',
        'is_published',
        'has_been_published',
        'is_draft',
        'is_visible',
        # Fields that need to be accessible for Fluent Pages processing
        'pk',
        'language_code',
        'get_current_language',
    ]

    def __init__(self, payload):
        if not payload.publishing_is_draft:
            raise ValueError(
                "'{0}' is not a DRAFT, and only DRAFT items may be wrapped"
                " by {1}".format(payload, self.__class__))
        self._draft_payload = payload
        # Prepare list of permitted attr/method names
        permitted_attr_list = self.DEFAULT_PERMITTED_ATTRS
        permitted_attr_list += getattr(self._draft_payload,
                                       'PUBLISHING_PERMITTED_ATTRS', [])
        self.permitted_attrs = dict([(i, None) for i in permitted_attr_list])

    def __getattr__(self, name):
        if name in self.permitted_attrs:
            return getattr(self._draft_payload, name)
        else:
            raise PublishingException(
                "Illegal attempt to access '{0}' on the DRAFT publishable item"
                " '{1}' in a public context, where only {2} can be accessed."
                " Get the published copy for this item by calling `published`"
                " / `visible` on the source queryset, or  `get_published` /"
                " `get_visible` on a single-item relationship"
                .format(name, self._draft_payload, self.permitted_attrs.keys())
            )

    def get_draft_payload(self):
        """
        Get the wrapped payload directly, but only do this if you are *sure*
        you know what you are doing and cannot bypass the publishing checks
        another way!
        """
        return self._draft_payload


def _exchange_for_published(qs):
    """
    Exchange the results in a queryset of publishable items for the
    corresponding published copies. This means the result QS has:
        - already published items in original QS
        - the published copies for drafts with published versions
        - unpublished draft items in the original QS are removed.

    Unfortunately we cannot just perform a normal queryset filter operation
    in the DB because FK/M2M relationships assigned in the site admin are
    *always* to draft objects. Instead we exchange draft items for
    published copies.
    """
    # TODO: Can this be lazily executed when a queryset is evaluated?
    # Currently any `published()` queryset will obtain its list available
    # published PKs when this function is called, which might not be
    # immediately before the queryset is evaluated. It might be possible
    # to pass a subquery instead of collecting PKs into a list to pass?
    published_version_pks = []
    for item in qs:
        # TODO Adjust checks so they don't fail if fields are not present
        # If item is draft and has a linked published copy, use that...
        if item.publishing_is_draft and item.publishing_linked_id:
            published_version_pks.append(item.publishing_linked_id)
        # ...otherwise if item is already the published copy, use it.
        elif not item.publishing_is_draft:
            published_version_pks.append(item.pk)
    # TODO: Salvage more attributes from the original queryset, such as
    # `annotate()`, `distinct()`, `select_related()`, `values()`, etc.
    qs = qs.model.objects.filter(pk__in=published_version_pks)
    # Restore ordering from original queryset.
    qs = _order_by_pks(qs, published_version_pks)
    return qs


def _order_by_pks(qs, pks):
    """
    Adjust the given queryset to order items according to the explicit ordering
    of PKs provided.

    This is a PostgreSQL-specific DB hack, based on:
    blog.mathieu-leplatre.info/django-create-a-queryset-from-a-list-preserving-order.html
    """
    pk_colname = '%s.%s' % (
        qs.model._meta.db_table, qs.model._meta.pk.column)
    clauses = ' '.join(
        ['WHEN %s=%s THEN %s' % (pk_colname, pk, i)
            for i, pk in enumerate(pks)]
    )
    ordering = 'CASE %s END' % clauses
    return qs.extra(
        select={'pk_ordering': ordering}, order_by=('pk_ordering',))

<commit_msg>Use more efficient DB queries when possible for publishing exchange<commit_after>from django.db import models
from django.db.models.query import QuerySet
from django.db.models.query_utils import Q
from django.utils.timezone import now
from fluent_pages.models.managers import UrlNodeQuerySet, UrlNodeManager
from model_utils.managers import PassThroughManagerMixin

from .middleware import is_draft_request_context, \
    is_publishing_middleware_active
from .utils import PublishingException


UNSET = object()  # Constant object to mark unset kwargs


class DraftItemBoobyTrap(object):
    """
    Wrapper for draft publishable items intended to prevent any misuse of
    draft items by accessing their data while in a published-only request
    context.

    This class will pass through a shortlist of safe attrs/methods to the
    wrapped payload object, just enough so the publishing status can be checked
    or the actual published copy can be obtained by downstream code.

    For any other attr/method access attempts, which most likely are the result
    of treating a draft item as if it were published, this class will raise an
    exception explaining why the access was illegal and recommend steps for
    devs to fix the problem.

    The list of permitted attr/method names can be extended from the values
    in ``DEFAULT_PERMITTED_ATTRS`` by specifying the list attribute
    ``PUBLISHING_DRAFT_PERMITTED_ATTRS`` on the draft object or class.
    """
    # Attribute or method names that are safe to access, for checking on draft
    # status or exchanging a draft payload for the published copy.
    DEFAULT_PERMITTED_ATTRS = [
        'get_draft_payload',
        'get_published',
        'get_visible',
        # NOTE: `get_draft` is not included here to discourage getting a draft
        'publishing_linked',
        'publishing_linked_id',
        'publishing_is_draft',
        'is_published',
        'has_been_published',
        'is_draft',
        'is_visible',
        # Fields that need to be accessible for Fluent Pages processing
        'pk',
        'language_code',
        'get_current_language',
    ]

    def __init__(self, payload):
        if not payload.publishing_is_draft:
            raise ValueError(
                "'{0}' is not a DRAFT, and only DRAFT items may be wrapped"
                " by {1}".format(payload, self.__class__))
        self._draft_payload = payload
        # Prepare list of permitted attr/method names
        permitted_attr_list = self.DEFAULT_PERMITTED_ATTRS
        permitted_attr_list += getattr(self._draft_payload,
                                       'PUBLISHING_PERMITTED_ATTRS', [])
        self.permitted_attrs = dict([(i, None) for i in permitted_attr_list])

    def __getattr__(self, name):
        if name in self.permitted_attrs:
            return getattr(self._draft_payload, name)
        else:
            raise PublishingException(
                "Illegal attempt to access '{0}' on the DRAFT publishable item"
                " '{1}' in a public context, where only {2} can be accessed."
                " Get the published copy for this item by calling `published`"
                " / `visible` on the source queryset, or  `get_published` /"
                " `get_visible` on a single-item relationship"
                .format(name, self._draft_payload, self.permitted_attrs.keys())
            )

    def get_draft_payload(self):
        """
        Get the wrapped payload directly, but only do this if you are *sure*
        you know what you are doing and cannot bypass the publishing checks
        another way!
        """
        return self._draft_payload


def _exchange_for_published(qs):
    """
    Exchange the results in a queryset of publishable items for the
    corresponding published copies. This means the result QS has:
        - already published items in original QS
        - the published copies for drafts with published versions
        - unpublished draft items in the original QS are removed.

    Unfortunately we cannot just perform a normal queryset filter operation
    in the DB because FK/M2M relationships assigned in the site admin are
    *always* to draft objects. Instead we exchange draft items for
    published copies.
    """
    # TODO: Can this be lazily executed when a queryset is evaluated?
    # Currently any `published()` queryset will obtain its list available
    # published PKs when this function is called, which might not be
    # immediately before the queryset is evaluated. It might be possible
    # to pass a subquery instead of collecting PKs into a list to pass?
    published_version_pks = []
    draft_version_pks = []
    is_exchange_required = False
    # Use direct DB query if possible (be sure to prioritise the ordering of
    # the draft items over the published items by checking the draft items
    # first, since the draft item ordering may be explicitly set via admin)...
    from .models import PublishingModel
    if issubclass(qs.model, PublishingModel):
        for pk, publishing_is_draft, publishing_linked_id in qs.values_list(
                'pk', 'publishing_is_draft', 'publishing_linked_id'):
            # If item is draft and if it has a linked published copy, exchange
            # the draft to get the published copy instead...
            if publishing_is_draft:
                draft_version_pks.append(pk)
                if publishing_linked_id:
                    published_version_pks.append(publishing_linked_id)
                    is_exchange_required = True
            # ...otherwise if item is already the published copy, use it.
            elif not publishing_is_draft:
                published_version_pks.append(pk)
    # ...otherwise we are forced to retrieve the real instances to check fields
    # and we may be dealing with a UrlNode model without our own publishing
    # fields so be defensive in our field lookups.
    else:
        for item in qs:
            # If item is draft and if it has a linked published copy, exchange
            # the draft to get the published copy instead...
            if getattr(item, 'publishing_is_draft', None):
                draft_version_pks.append(item.pk)
                if getattr(item, 'publishing_linked_id', None):
                    published_version_pks.append(item.publishing_linked_id)
                    is_exchange_required = True
            # ...otherwise if item is already the published copy, use it.
            elif getattr(item, 'is_published', None):
                published_version_pks.append(pk)
    # Only perform exchange query and re-ordering if necessary
    if not is_exchange_required:
        # If no exchange is required, we must make sure any draft items we
        # found are excluded from the queryset since we won't be filtering by
        # published-item PKs
        return qs.exclude(pk__in=draft_version_pks)
    else:
        # TODO: Salvage more attributes from the original queryset, such as
        # `annotate()`, `distinct()`, `select_related()`, `values()`, etc.
        qs = qs.model.objects.filter(pk__in=published_version_pks)
        # Restore ordering from original queryset.
        qs = _order_by_pks(qs, published_version_pks)
        return qs


def _order_by_pks(qs, pks):
    """
    Adjust the given queryset to order items according to the explicit ordering
    of PKs provided.

    This is a PostgreSQL-specific DB hack, based on:
    blog.mathieu-leplatre.info/django-create-a-queryset-from-a-list-preserving-order.html
    """
    pk_colname = '%s.%s' % (
        qs.model._meta.db_table, qs.model._meta.pk.column)
    clauses = ' '.join(
        ['WHEN %s=%s THEN %s' % (pk_colname, pk, i)
            for i, pk in enumerate(pks)]
    )
    ordering = 'CASE %s END' % clauses
    return qs.extra(
        select={'pk_ordering': ordering}, order_by=('pk_ordering',))


{'fraction_non_alphanumeric': 0.06645592355772685, 'fraction_numerical': 0.0008621309002083483, 'mean_word_length': 2.680592279217345, 'pattern_counts': {'":': 0, '<': 3, '<?xml version=': 0, '>': 3, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '4527513', 'n_tokens_mistral': 3794, 'n_tokens_neox': 3671, 'n_words': 1442}