6

This is very similar to this question, but unfortunately, I still couldn't get it working.

I have a model, with a property that combines a few fields:

class Specimen(models.Model):
    lab_number = ...
    patient_name = ...
    specimen_type = ...

    @property
    def specimen_name(self):
        return f"{self.lab_number}_{self.patient_name}_{self.specimen_type}"

In Django Admin, when someone does a search, I can use the search_fields attribute in the Model Admin to specify real fields, but not the specimen_name custom field:


def specimen_name(inst):
    return inst.specimen_name
specimen_name.short_description = "Specimen Name"

class SpecimenModelAdmin(admin.ModelAdmin):
    list_display = ('specimen_name', 'patient_name', 'lab_number', 'specimen_type')
    search_fields = ('patient_name', 'lab_number', 'specimen_type')

Doing a search using the code above, it will search the individual fields, but if I try to search for a full specimen_name in Django Admin, it won't find it, because none of the fields contain the exact, full specimen name.

The SO question I linked to above pointed me in the right direction - using get_search_results. My code now looks something like this:

class SpecimenModelAdmin(admin.ModelAdmin):
    ...
    search_fields = ('patient_name', 'lab_number', 'specimen_type')

    def get_search_results(self, request, queryset, search_term):
        if not search_term:
            return queryset, False

        queryset, may_have_duplicates = super().get_search_results(
            request, queryset, search_term,
        )

        search_term_list = search_term.split(' ')
        specimen_names = [q.specimen_name for q in queryset.all()]
        results = []

        for term in search_term_list:
            for name in specimen_names:
                if term in name:
                    results.append(name)
                    break

        # Return original queryset, AND any new results we found by searching the specimen_name field
        # The True indicates that it's possible that we will end up with duplicates
        # I assume that means Django will make sure only unique results are returned when that's set
        return queryset + results, True

As far as I know, I can't do a queryset.filter(specimen_name=SOMETHING). .filter won't recognize the @property method as a field in needs to search. That's why I write my own loop to do the searching.

The code above will obviously not work. You can't just add a list to a queryset. How would I return an actual queryset?

John
  • 2,551
  • 3
  • 30
  • 55
  • [filter by property](https://stackoverflow.com/a/1205389/8283848) is not feasible, yet – JPG Sep 26 '21 at 17:24
  • 1
    Why not change the `specimen_name` property into a field? Then you'll have full access to the Django ORM. You can override the save method so this field is set automatically. Sometimes the simplest solutions are best. – Daniel Sep 26 '21 at 17:25
  • if you do so, don't forget to update the `specimen_name` field whenever you change any other fields @Daniel – JPG Sep 26 '21 at 17:26

2 Answers2

6

The correct way to filter on a property is to make an equivalent annotation for the property and filter on that instead. Looking at your property all it does is it concatenates some of the fields, corresponding to that Django has the Concat database function. Hence you can do the following annotation:

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


queryset = queryset.annotate(
    specimen_name=Concat("lab_number", Value("_"), "patient_name", Value("_"), "specimen_type")
)
# Note: If you use Django version >=3.2 you can use "alias" instead of "annotate"

Then you can change your get_search_results as follows:

from django.db.models import Value, Q
from django.db.models.functions import Concat
from django.utils.text import (
    smart_split, unescape_string_literal
)


class SpecimenModelAdmin(admin.ModelAdmin):
    ...
    search_fields = ('patient_name', 'lab_number', 'specimen_type')

    def get_search_results(self, request, queryset, search_term):
        queryset = queryset.annotate(
            specimen_name=Concat(
                "lab_number",
                Value("_"),
                "patient_name",
                Value("_"),
                "specimen_type"
            )
        )
        queryset, may_have_duplicates = super().get_search_results(request, queryset, search_term)
        for bit in smart_split(search_term):
            if bit.startswith(('"', "'")) and bit[0] == bit[-1]:
                bit = unescape_string_literal(bit)
             queryset = queryset.filter(Q(specimen_name__icontains=bit))
        return queryset, may_have_duplicates

Note: The above will likely stop giving you results unless you set search_fields to an empty tuple / list.

Continuing down this line perhaps with the annotation you can have specimen_name in search_fields by overriding get_queryset and hence skip overriding get_search_results:

class SpecimenModelAdmin(admin.ModelAdmin):
    ...
    search_fields = ('patient_name', 'lab_number', 'specimen_type', 'specimen_name')
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        qs = qs.annotate(
            specimen_name=Concat(
                "lab_number",
                Value("_"),
                "patient_name",
                Value("_"),
                "specimen_type"
            )
        )
        return qs
Abdul Aziz Barkat
  • 19,475
  • 3
  • 20
  • 33
0

Based on Abdul's answer, which helped tremendously, I was able to modify it slightly to get what I wanted:

def get_search_results(self, request, queryset, search_term):
    # The results of the built-in search, based on search_fields
    queryset_a, may_have_duplicates = super().get_search_results(request, queryset, search_term)

    # Queryset B starts off equal to the original queryset with
    # anotations
    queryset_b = queryset.alias(
        speci_name=Concat(
            "lab_number",
            Value("_"),
            Replace("patient_name", Value(" "), Value(".")),
            Value("_"),
            Cast("alberta_health_number", CharField())
        )
    )

    # Filter out queryset_b on every search term
    for bit in smart_split(search_term):
        if bit.startswith(('"', "'")) and bit[0] == bit[-1]:
            bit = unescape_string_literal(bit)
        queryset_b = queryset_b.filter(Q(speci_name__icontains=bit))

    # Return both querysets
    # Since we're doing 2 separate searches and combining them, it's
    # not impossible for there to be duplicates, so we set
    # may_have_duplicates return value to True, which will have Django
    # filter out the duplicates
    return (queryset_a | queryset_b), True

The slight issue I was having with Abdul's code was that instead of doing a search using the search_fields, and adding those results to the results of a search based on this new, custom field, it was combining the filters.

If you did a search based on the full specimen_name field, super() would return an empty queryset, and filtering it further at that point would return another empty queryset.

Here, my code does the default search by calling super(), then the search based on the new custom field, and adds the results together.

When you do a search in Django Admin, by default, it searches for records where ANY of the fields match your search terms. Abdul's code was making it so that the search term had to match BOTH the custom field, and any of the search fields. A record that only matched the custom field was ignored. My code fixes that.

Thanks Abdul - I learned a lot from your code.

John
  • 2,551
  • 3
  • 30
  • 55