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))
В вышеуказанном состоянии фильтрация осуществляется по значению "имя + фамилия".
Перед применением фильтрации
После применения фильтрации
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 для достижения более простого и менее запутанного решения, и я нахожу его более логичным, но если вы хотите продолжить тот способ, которым вы определили модели, вы можете аннотировать к каждому приказу имя агента, который отвечает за назначение, что:
- Его заказчик совпадает с заказчиком заказа.
- Его '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'
)
)
Обратите внимание, что, поскольку мы знаем, что клиент и год уникальны в каждом задании, подзапрос будет содержать одну запись.