Связанное с фильтром поле против другой связанной модели M2M в Django

Итак, у меня есть система бронирования. Агенты (люди и организации, отправляющие заказы) разрешены только для бронирования в категориях, которые мы им назначаем. Многие агенты могут назначать одни и те же категории. Это простой для многих. Вот представление о том, как выглядят модели:

class Category(models.Model):
    pass

class Agent(models.Model):
    categories = models.ManyToManyField('Category')

class Booking(models.Model):
    agent = models.ForeignKey('Agent')
    category = models.ForeignKey('Category')

Итак, когда приходит заказ, мы динамически выделяем категорию, на основе которой доступны агенту. Агент обычно не указывает.

Можно ли выбрать "Заказы", ​​в которых "Booking.kategory" не находится в Booking.agent.categories?

Мы только что заметили, что по милости глупой ошибки администратора некоторым агентам разрешалось отправлять заказы в любую категорию. Это оставило нас с тысячами заказов в неправильном месте.

Я могу исправить это, но я могу заставить его работать только с помощью поиска вложенности:

for agent in Agent.objects.all():
    for booking in Booking.objects.filter(agent=agent):
        if booking.category not in agent.categories.all():
            # go through the automated allocation logic again

Это работает, но он очень медленный. Это много данных, которые летают между базой данных и Django. Это тоже не одноразовый. Я хочу периодически проверять новые заказы, чтобы убедиться, что они находятся в правильном месте. Не кажется невозможным, чтобы произошла другая проблема с администратором, поэтому после проверки базы данных агента я хочу запросить Заказы, которые не входят в их категории агентов.

Опять же, вложенные запросы будут работать не так, как только наши наборы данных вырастут до миллионов (и далее), я бы хотел сделать это более эффективно.

Мне кажется, что это можно сделать с помощью поиска F(), примерно так:

from django.db.models import F
bad = Booking.objects.exclude(category__in=F('agent__categories'))

Но это не работает: TypeError: 'Col' object is not iterable

Я также пробовал .exclude(category=F('agent__categories')), и пока он более счастлив с синтаксисом, он не исключает "правильные" заказы.

Какова секретная формула для выполнения этого запроса F() на M2M?


Чтобы скрыть то, что мне нужно, я создал репозиторий Github с этими моделями (и некоторые данные). Пожалуйста, используйте их для написания запроса. Нынешний единственный ответ на вопрос и проблема, которую я видел на моих "реальных" данных тоже.

git clone https://github.com/oliwarner/djangorelquerytest.git
cd djangorelquerytest
python3 -m venv venv
. ./venv/bin/activate
pip install ipython Django==1.9a1

./manage.py migrate
./manage.py shell

И в оболочке огонь в:

from django.db.models import F
from querytest.models import Category, Agent, Booking
Booking.objects.exclude(agent__categories=F('category'))

Это ошибка? Есть ли правильный способ достичь этого?

Ответ 1

Есть вероятность, что я могу ошибаться, но я думаю, что делать это в обратном направлении должно быть трюком:

bad = Booking.objects.exclude(agent__categories=F('category'))

Edit

Если выше не будет работать, вот еще одна идея. Я пробовал аналогичную логику в настройке, которую у меня есть, и она работает. Попробуйте добавить промежуточную модель для ManyToManyField:

class Category(models.Model):
    pass

class Agent(models.Model):
    categories = models.ManyToManyField('Category', through='AgentCategory')

class AgentCategory(models.Model):
    agent = models.ForeignKey(Agent, related_name='agent_category_set')
    category = models.ForeignKey(Category, related_name='agent_category_set')

class Booking(models.Model):
    agent = models.ForeignKey('Agent')
    category = models.ForeignKey('Category')

Затем вы можете сделать запрос:

bad = Booking.objects.exclude(agent_category_set__category=F('category'))

Конечно, указание промежуточной модели имеет свои собственные последствия, но я уверен, что вы сможете справиться с ними.

Ответ 2

Обычно при работе с отношениями m2m я беру гибридный подход. Я бы разбил проблему на две части: часть python и sql. Я нахожу, что это ускоряет запрос и не требует сложного запроса.

Первое, что вы хотите сделать, это получить сопоставление агента с категорией, а затем использовать это сопоставление для определения категории, которая не входит в присваивание.

def get_agent_to_cats():
    # output { agent_id1: [ cat_id1, cat_id2, ], agent_id2: [] }
    result = defaultdict(list)

    # get the relation using the "through" model, it is more efficient
    # this is the Agent.categories mapping
    for rel in Agent.categories.through.objects.all():
        result[rel.agent_id].append(rel.category_id)
    return result


def find_bad_bookings(request):
    agent_to_cats = get_agent_to_cats()

    for (agent_id, cats) in agent_to_cats.items():
        # this will get all the bookings that NOT belong to the agent category assignments
        bad_bookings = Booking.objects.filter(agent_id=agent_id)
                                         .exclude(category_id__in=cats)

        # at this point you can do whatever you want to the list of bad bookings
        bad_bookings.update(wrong_cat=True)            

    return HttpResponse('Bad Bookings: %s' % Booking.objects.filter(wrong_cat=True).count())

Вот небольшая статистика, когда я запускал тест на своем сервере: 10 000 агентов 500 Категории 2,479,839 Агент для назначения категорий 5 000 000 заказов

2,509,161 Плохие заказы. Общая продолжительность 149 секунд

Ответ 3

Решение 1:

Вы можете найти хорошие заказы, используя этот запрос

good = Booking.objects.filter(category=F('agent__categories'))

Вы можете проверить запрос sql для этого

print Booking.objects.filter(category=F('agent__categories')).query

Таким образом, вы можете исключить хорошие заказы из всех заказов. Решение:

Booking.objects.exclude(id__in=Booking.objects.filter(category=F('agent__categories')).values('id'))

Он создаст MySql-вложенный запрос, который является самым оптимизированным запросом MySql для этой проблемы (насколько я знаю).

Этот MySql-запрос будет немного тяжелым, так как ваша база данных огромна, но она попадет в базу данных только один раз вместо вашей первой попытки циклов, которая будет срабатывать при бронировании * agent_categories times.

Кроме того, вы можете сделать набор данных меньше, используя фильтрацию по дате, если вы их сохраняете, и у вас есть аппроксимация, когда началось неправильное бронирование.

Вы можете использовать указанную выше команду, чтобы проверить наличие непоследовательных заказов. Но я бы порекомендовал вам переместиться в админ-форму и проверить при заказе, если категория верна или нет. Также вы можете использовать некоторый javascript для добавления только категорий в форме администратора, которые присутствуют для выбранного/зарегистрированного агента в то время.

Решение 2:

используйте prefetch_related, это значительно сократит ваше время, потому что очень мало ударов базы данных.

читайте об этом здесь: https://docs.djangoproject.com/en/1.8/ref/models/querysets/

for agent in Agent.objects.all().prefetch_related('bookings, categories'):
    for booking in Booking.objects.filter(agent=agent):
        if booking.category not in agent.categories.all():

Ответ 4

Это может ускорить его...

for agent in Agent.objects.iterator():
    agent_categories = agent.categories.all()
    for booking in agent.bookings.iterator():
        if booking.category not in agent_categories:
            # go through the automated allocation logic again

Ответ 5

Возможно, это не то, что вы ищете, но вы можете использовать необработанный запрос. Я не знаю, можно ли это сделать полностью в ORM, но это работает в вашем реестре github:

Booking.objects.raw("SELECT id \
                     FROM querytest_booking as booking \
                     WHERE category_id NOT IN ( \
                         SELECT category_id \
                         FROM querytest_agent_categories as agent_cats \
                         WHERE agent_cats.agent_id = booking.agent_id);")

Я предполагаю, что имена таблиц будут разными для вас, если ваше приложение не будет называться querytest. Но в любом случае это можно повторить, чтобы вы могли подключить свою собственную логику.

Ответ 6

Ты был почти там. Сначала создайте два элемента бронирования:
# b1 has a "correct" agent
b1 = Booking.objects.create(agent=Agent.objects.create(), category=Category.objects.create())
b1.agent.categories.add(b1.category)

# b2 has an incorrect agent
b2 = Booking.objects.create(agent=Agent.objects.create(), category=Category.objects.create())

Вот список всех неправильных заказов (например: [b2]):

# The following requires a single query because
# the Django ORM is pretty smart
[b.id for b in Booking.objects.exclude(
    id__in=Booking.objects.filter(
        category__in=F('agent__categories')
    )
)]
[2]

Обратите внимание, что по моему опыту следующий запрос не вызывает ошибок, но по какой-то неизвестной причине результат также неверен:

Booking.objects.exclude(category__in=F('agent__categories'))
[]