Django фильтр по нескольким отношениям моделей

Пусть упрощенная версия моих моделей выглядит следующим образом:

class Order (models.Model):
    customer = models.ForeignKey("Customer", on_delete=models.RESTRICT)
    request_date = models.DateField()
    price = models.DecimalField(max_digits=10, decimal_places=2)

    @property
    def agent_name(self):
        assignment = Assignment.objects.get(assig_year = self.request_date.year, customer = self.customer)
        if assignment is not None:
            return assignment.sales_agent.name + ' ' + assignment.sales_agent.surname
        else:
            return 'ERROR'

class Company (models.Model):
    pass

class Customer (Company):
    pass

class Assignment (models.Model):
    assig_year = models.PositiveSmallIntegerField()
    customer = models.ForeignKey("Customer", on_delete=models.CASCADE)
    sales_agent = models.ForeignKey("Agent", on_delete=models.CASCADE)

class Employee (models.Model):
    name = models.CharField(max_length=32)
    surname = models.CharField(max_length=32)

class Agent (Employee):
    pass

В одном из моих представлений я отображаю все заказы, указывая соответствующего торгового агента, клиента, дату и цену, следующим образом:

def GetOrders(request):
    orders = Order.objects.order_by('-request_date')
    
    template = loader.get_template('orders.html')
    context = {
        'orders' : orders,
    }
    return HttpResponse(template.render(context,request))

где файл orders.html выглядит примерно так:

<!DOCTYPE html>
<html>
  <head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <main>
      <table>
        <thead>
          <th>Agent</th>
          <th>Customer</th>
          <th>Date</th>
          <th>Price</th>
        </thead>
        <tbody>
          {% for x in orders %}
            <td>{{ x.agent_name }}</td>
            <td>{{ x.customer.name }}</td>
            <td>{{ x.request_date }}</td>
            <td>{{ x.price }}</td>
            </tr>
          {% endfor %}
        </tbody>
      </table>
    </main>
  </body>
</html>

Теперь я хотел бы добавить возможность фильтрации в html, чтобы выбрать только тех торговых агентов, которые меня интересуют, но это мой первый проект на Django, и я не знаю, как разобраться со всеми отношениями, через которые мне нужно пройти, чтобы проверить имя торгового агента. Я попытался воспользоваться свойством agent_name, например, так:

<!DOCTYPE html>
<html>
  <head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <main>
      <div class="filters">
        <form action="" method="GET">
          <div class="row">
            <div class="col-xl-3">
              <label>Agent:</label>
              <input type="text" class="form-control" placeholder="Name" name="name" {% if name %} value = "{{ name }}" {% endif %}>
            </div>
            <div class="col-xl-2" style="padding-top: 2%;">
              <button type="submit" class="btn custom-btn">Filter</button>
            </div>
          </div>
        </form>
      </div>
      <p/>
      <table>
        <thead>
          <th>Agent</th>
          <th>Customer</th>
          <th>Date</th>
          <th>Price</th>
        </thead>
        <tbody>
          {% for x in orders %}
            <td>{{ x.agent_name }}</td>
            <td>{{ x.customer.name }}</td>
            <td>{{ x.request_date }}</td>
            <td>{{ x.price }}</td>
            </tr>
          {% endfor %}
        </tbody>
      </table>
    </main>
  </body>
</html>

и мое мнение теперь сводится к следующему:

def GetOrders(request):
    orders = Order.objects.order_by('-request_date')

    com = request.GET.get('name')
    if com != '' and com is not None:
        orders = orders.filter(Q(agent_name__icontains=com))
    
    template = loader.get_template('orders.html')
    context = {
        'orders' : orders,
    }
    return HttpResponse(template.render(context,request))

но, похоже, я не могу использовать его в качестве критерия фильтрации, потому что это не настоящее поле модели, и я получаю FieldError в ответ ("Cannot resolve keyword 'agent_name' into field").

Есть идеи?

Короткий ответ: вы не можете сделать это так, как описано, потому что функция @property устанавливается через python после выполнения запроса, т.е. она использует возвращенный запрос как данные для функции.

Кроме того, ваше свойство делает sql-вызов для каждого имени_агента. Это не проблема для одного агента, но циклическое обращение к 50 агентам будет неэффективным.

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

from django.db.models.functions import Concat
from django.db.models import Value


com = request.GET.get('name')
if com != '' and com is not None:
    #start definition of orders with ( so we can break up the line for readability
    orders = (
        Order.objects.annotate(
            #Combine firstname, space, and last name into new field
            full_name = Concat(
                'order__assignment__sales_agent__name',
                 Value(' '),
                 'order__assignment__sales_agent__surname'
             )
         )
         #filter on our annotated field from the form
         .filter(full_name__icontains=com)
         .order_by('-request_date')
     )

Я не уверен в связи между Заказом и Назначением, но я подтвердил, что один Заказ и Назначение связаны через свойство agent_name.

Это то, что вам нужно изменить с помощью точных отношений, но сейчас я подключил заказ с Foreignkey к модели Assignment для тестирования.

from django.db import models

# Create your models here.
class Order (models.Model):
  customer = models.ForeignKey("Customer", on_delete=models.RESTRICT)
  request_date = models.DateField()
  price = models.DecimalField(max_digits=10, decimal_places=2)

class Company (models.Model):
  name = models.CharField(max_length=24)

class Customer (Company):
  pass

class Assignment (models.Model):
  assig_year = models.PositiveSmallIntegerField()
  customer = models.ForeignKey("Customer", on_delete=models.CASCADE)
  sales_agent = models.ForeignKey("Agent", on_delete=models.CASCADE)
  order = models.ForeignKey("Order", on_delete=models.CASCADE, related_name="assignment")

class Employee (models.Model):
  name = models.CharField(max_length=32)
  surname = models.CharField(max_length=32)

class Agent (Employee):
  pass

Ваше имя_агента составляет значение имени_агента через поле "Имя, фамилия" объекта, удовлетворяющего условиям объекта "Назначение".

    @property
    def agent_name(self):
        assignment = Assignment.objects.get(assig_year = self.request_date.year, customer = self.customer)
        if assignment is not None:
            return assignment.sales_agent.name + ' ' + assignment.sales_agent.surname
        else:
            return 'ERROR'

Фильтр в django должен иметь поле, используемое в качестве фильтра на объекте, который существует внутри Queryset.

Поэтому я добавил agent_name с помощью метода annotate, а затем объединил значения полей name и surname с помощью Concat.

from django.template import loader
from django.db.models import Q, Value
from django.db.models.functions import Concat
from django.shortcuts import HttpResponse
from .models import Order

# Create your views here.
def GetOrders(request):
    orders = Order.objects.annotate(
        agent_name=Concat('assignment__sales_agent__name', Value(' '), 'assignment__sales_agent__surname')
    ).order_by('-request_date')

    com = request.GET.get('name')
    if com != '' and com is not None:
        orders = orders.filter(Q(agent_name__icontains=com))
    
    template = loader.get_template('orders.html')
    context = {
        'orders' : orders,
    }
    return HttpResponse(template.render(context,request))

В вышеуказанном состоянии фильтрация осуществляется по значению "имя + фамилия".


Перед применением фильтрации

enter image description here

После применения фильтрации

Проверьте часть строки запроса url. enter image description here

class Assignment (models.Model):
    assig_year = models.PositiveSmallIntegerField()
    customer = models.ForeignKey("Customer", on_delete=models.CASCADE)
    sales_agent = models.ForeignKey("Agent", on_delete=models.CASCADE)

    class Meta:
        #unique key year + customer
        constraints = [
            UniqueConstraint(
                fields=['assig_year', 'customer'], name='Primary_Key_Assignment'
            )
        ]

class Order (models.Model):
    assignment = models.ForeignKey(Assignment, on_delete=models.RESTRICT, related_name="orders")
    request_date = models.DateField()
    price = models.DecimalField(max_digits=10, decimal_places=2)

Причина:

В комментариях вы беспокоились о дублировании данных и ER-петлях. Но у вас уже есть ER-петли и дублирование, поскольку соединение customer есть и у Order, и у Assignment. Хотя в этом нет ничего плохого, это также несколько избыточно, учитывая ограничение на то, что один и тот же торговый агент будет обрабатывать все заказы клиента.

При предложенном выше изменении мы удаляем FK customer из Order и вместо него добавляем FK Assignment, а FK из Assignment в Customer сохраняем. Дублирование данных устранено, а ER-спагетти устранено (поскольку цепочка зависимостей теперь линейна):

Order -> Assignment -> Customer

Кроме того, нужный вам вид теперь может быть синтаксически намного проще:

def GetOrders(request):
    com = request.GET.get('name')
    if com != '' and com is not None:

        # This is the slightly more expensive but maybe more readable version:
        assignments = Assignment.objects.filter(sales_agent=com)
        orders = Orders.objects.filter(assignment__in=assignments)

        # I haven't verified this attempt at a DB optimized version, but I think it's on par:
        orders = Order.objects.select_related('assignment').filter(assignment__sales_agent=com)
    else:
        return Order.objects.none() # Or however you want to handle the case of there being no assignments/orders for a given sales agent

    template = loader.get_template('orders.html')
    context = {
        'orders' : orders,
    }
    return HttpResponse(template.render(context,request))

В качестве бонуса, если вам когда-нибудь понадобится представление, например, для просмотра заказов за год, вы получите его бесплатно, просто вызвав assignment.orders. Это работает как для торговых агентов, так и для клиентов, поскольку обе эти сущности используют Assignment в качестве посредника.

Вы должны использовать GeneratedField для agent_name поля:

from django.db.models import Value as V
from django.db.models.functions import Concat

class Employee (models.Model):
    name = models.CharField(max_length=32)
    surname = models.CharField(max_length=32)

    agent_name = models.GeneratedField(
        expression = Concat('name', V(' '), 'surname'),
        output_field = models.CharField(max_length = 32 * 2),
        db_persist = True
    )

Теперь в представлении вы можете фильтровать по agent_name, как по любому другому полю:

def get_orders(request):

    if com := request.GET.get('name'):
        assignment = Assignment.objects.get(agent_name__icontains=com)
        orders = Order.objects.filter(
            customer=assignment.customer, 
            request_date__year=assignment.assig_year,
        )

    else:
        raise Exception('no name')

    context = { 'orders' : orders }
    return render(request, context)

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

Один из способов решения этой проблемы - аннотировать ваш набор запросов Order именем агента с помощью Subquery. Это позволит вам фильтровать непосредственно по agent_name. Вот как это можно сделать:

Сначала импортируйте необходимые функции в верхнюю часть файла views:

from django.db.models import Subquery, OuterRef, Value
from django.db.models.functions import Concat, ExtractYear

Затем измените ваше представление GetOrders следующим образом:

def GetOrders(request):
    # Subquery to get the agent's full name for each order
    assignments = Assignment.objects.filter(
        customer=OuterRef('customer'),
        assig_year=ExtractYear(OuterRef('request_date'))
    ).annotate(
        agent_full_name=Concat('sales_agent__name', Value(' '), 'sales_agent__surname')
    )

    # Annotate the orders with the agent's name
    orders = Order.objects.annotate(
        agent_name=Subquery(assignments.values('agent_full_name')[:1])
    )

    # Apply the filter if a name is provided
    com = request.GET.get('name')
    if com:
        orders = orders.filter(agent_name__icontains=com)

    orders = orders.order_by('-request_date')

    context = {'orders': orders}
    return render(request, 'orders.html', context)

Таким образом, вы добавляете поле agent_name к каждому объекту Order в вашем наборе запросов, по которому затем можно фильтровать. Это также способствует эффективности запросов к базе данных.

В своем шаблоне вы можете использовать {{ x.agent_name }}, как и раньше.

Надеюсь, это поможет!

Лично я предлагаю способ, предложенный в ответе @Vegard для достижения более простого и менее запутанного решения, и я нахожу его более логичным, но если вы хотите продолжить тот способ, которым вы определили модели, вы можете аннотировать к каждому приказу имя агента, который отвечает за назначение, что:

  1. Его заказчик совпадает с заказчиком заказа.
  2. Его 'assig_year' равен году запроса_даты заказа.

Для этого, я думаю, лучше использовать функцию Coalesce, чтобы вернуть хотя бы заполнитель (потому что для заказа может не быть назначения), так что запрос будет выглядеть так:

from django.db.models import OuterRef, Subquery, F, Value, CharField, Concat
from django.db.models.functions import Coalesce

# Now, orders_with_agent will contain Orders annotated with agent_name or 'No agent' if no agent was found.
orders_with_agent = Order.objects.annotate(
    agent_name=Coalesce(
        Subquery(
            Assignment.objects.filter(
                customer=OuterRef('customer'),
                assig_year=OuterRef('request_date__year')
            ).values(
                # Here we concatenate the agent's name and surname
                full_name=Concat(
                    F('sales_agent__name'),
                    Value(' '),
                    F('sales_agent__surname'),
                    output_field=CharField()
                )
            )[:1],  # Get the first matching assignment
            output_field=CharField()
        ),
        Value('No Agent')  # If no matching assignment, return 'No Agent'
    )
)

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

Вернуться на верх