pythondjangosqlitedjango-modelsdjango-views

How do I filter across multiple model relationships?


My models:

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 Meta:
        #unique key year + customer
        constraints = [
            UniqueConstraint(
                fields=['assig_year', 'customer'], name='Primary_Key_Assignment'
            )
        ]

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

class Agent (Employee):
    pass

One assignment relates each customer to a sales agent for a given year. Each customer may have several orders along the year and the agent assigned to the customer is accountable for serving all of them. In one of my views I am displaying all orders by listing their corresponding sales agent, customer, date and price:

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>

I want to add some filtering capability to select the sales agent I'm interested in. I don't know how to deal with relationships to check the sales agent. I tried the agent_name property:

<!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>

My view turns to:

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))

But I cannot use it as a filter criterium because it is not a real model field and I get a FieldError:

Cannot resolve keyword 'agent_name' into field


Solution

  • 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)
    

    The reason being:

    In the comments, you were worried about data duplication and ER loops. But you already had ER spaghetti and duplication by having a customer connection on both Order and Assignment. While there's nothing wrong with that, it's also somewhat redundant given the constraint that the same sales agent will handle all the customer's orders.

    With the above proposed change, we remove the customer FK from Order and instead add an FK Assignment, and we keep the FK from Assignment to Customer. Data duplication is eliminated and ER spaghetti is eliminated (since the dependency chain is now linear):

    Order -> Assignment -> Customer
    

    Additionally, the view you need can now be syntactically much simpler:

    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))
    

    As a bonus, if you ever need a view to see orders per year, for example, you get that for free now, simply invoke assignment.orders. Which works for both sales agents and customers, as both of those entities use Assignment as the middle man.