Django admin list_display reverse to parent - django

I have 2 models:
1: KW (individual keywords)
2: Project (many keywords can belong to many different projects)
class KW(models.Model):
...
project = models.ManyToManyField('KWproject', blank=True)
class KWproject(models.Model):
ProjectKW = models.CharField('Main Keyword', max_length=1000)
author = models.ForeignKey(User, editable=False)
Now when user is in Admin for KWproject they should be able to see all keywords belonging to selected project in list_display. I achieved this but it doesn't feel like proper way.
class ProjectAdmin(admin.ModelAdmin):
form = ProjectForm
list_display = ('Keywordd', 'author')
def Keywordd(self, obj):
return '%s' % (obj.id, obj.ProjectKW)
Keywordd.allow_tags = True
Keywordd.admin_order_field = 'ProjectKW'
Keywordd.short_description = 'ProjectKW'
Is there better way to link and then list_display all items that have reverse relationship to the model? (via "project" field in my example)

As per the Django Admin docs:
ManyToManyField fields aren’t supported, because that would entail
executing a separate SQL statement for each row in the table. If you
want to do this nonetheless, give your model a custom method, and add
that method’s name to list_display. (See below for more on custom
methods in list_display.)
So, you may opt to implement a custom model method like so:
# models.py
class KW(models.Model):
...
project = models.ManyToManyField('KWproject', blank=True)
class KWproject(models.Model):
ProjectKW = models.CharField('Main Keyword', max_length=1000)
author = models.ForeignKey(User, editable=False)
def all_keywords(self):
# Retrieve your keywords
# KW_set here is the default related name. You can set that in your model definitions.
keywords = self.KW_set.values_list('desired_fieldname', flat=True)
# Do some transformation here
desired_output = ','.join(keywords)
# Return value (in example, csv of keywords)
return desired_output
And then, add that model method to your list_display tuple in your ModelAdmin.
# admin.py
class ProjectAdmin(admin.ModelAdmin):
form = ProjectForm
list_display = ('Keywordd', 'author', 'all_keywords')
def Keywordd(self, obj):
return '%s' % (obj.id, obj.ProjectKW)
Keywordd.allow_tags = True
Keywordd.admin_order_field = 'ProjectKW'
Keywordd.short_description = 'ProjectKW'
Do take note: This can potentially be a VERY EXPENSIVE operation. If you are showing 200 rows in the list, then a request to the page will execute 200 additional SQL queries.

Related

Using `search=term1,term2` to match multiple tags for the same object using DRF

I'm uplifting an old Django 1.11 codebase to recent versions of Django and Django Rest Framework, but I've run into a hard wall around how the ?search=... filter works when using multiple terms in recent versions of Django Rest Framework.
Up until DRF version 3.6.3 it was possible to do a ?search=term1,term2 endpoint request and have DRF return objects with many-to-many relations in which both search terms matched the same field name, e.g if the model had a many-to-many field called tags relating to some model Tag, then an object with tags cake and baker could be found by DRF by asking for ?search=cake,baker.
In the codebase I'm uplifting, the (reduced) code for this looks like:
class TagQuerySet(models.query.QuerySet):
def public(self):
return self
class Tag(models.Model):
name = models.CharField(unique=True, max_length=150)
objects = TagQuerySet.as_manager()
def _get_entry_count(self):
return self.entries.count()
entry_count = property(_get_entry_count)
def __str__(self):
return str(self.name)
class Meta:
ordering = ['name',]
class Entry(models.Model):
title = models.CharField(max_length=140)
description = models.CharField(max_length=600, blank=True)
tags = models.ManyToManyField(Tag, related_name='entries', blank=True)
def __str__(self):
return str(self.title)
class Meta:
verbose_name_plural = "entries"
ordering = ['-id']
class EntryCustomFilter(filters.FilterSet):
tag = django_filters.CharFilter(name='tags__name', lookup_expr='iexact', )
class Meta:
model = Entry
fields = [ 'tags', ]
class EntriesListView(ListCreateAPIView):
"""
- `?search=` - Searches title, description, and tags
- `&format=json` - return results in JSON rather than HTML format
"""
filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter, )
filter_class = EntryCustomFilter
search_fields = ('title', 'description', 'tags__name', )
parser_classes = ( JSONParser, )
However, this kind of behaviour for search got inadvertently changed in 3.6.4, so that DRF now instead only matches if a single relation found through a many-to-many field matches all terms. So, an Entry with a tags field that has relations to Tag(name="cake") and Tag(name="baker") no longer matches, as there is no single Tag that matches both terms, but an Entry with Tag(name="baker of cake") and Tag(name="teller of tales") does match, as there is a single relation that matches both terms.
There is (at least at the time of writing) no documentation that I can find that explains how to achieve this older behaviour for the generic search filter, nor can I find any previously asked questions here on Stackoverflow about making DRF work like this again (or even "at all"). There are some questions around specific field-named filters, but none for search=.
So: what changes can I make here so that ?search=... keeps working as before, while using a DRF version 3.6.4+? I.e. how does one make the ?search=term1,term2 filter find models in which many-to-many fields have separate relations that match one or more of the specified terms?
This is expected behavior in DRF, introduced in order to optimize the M2M search/filter, as of 3.6.4. The reason this was introduced was to prevent a combinatorial explosion when using more than one term (See "SearchFilter time grows exponentially by # of search terms" and its associated PR "Fix SearchFilter to-many behavior/performance " for more details).
In order to perform the same kind of matching as in 3.6.3 and below, you need to create a custom search filter class by extending filters.SearchFilter, and add a custom implementaiton for the filter_queryset definition (the original definition can be found here for DRF v3.6.3).
from rest_framework import filters
import operator
from functools import reduce
from django.db import models
from rest_framework.compat import distinct
class CustomSearchFilter(filters.SearchFilter):
def required_m2m_optimization(self, view):
return getattr(view, 'use_m2m_optimization', True)
def get_search_fields(self, view, request):
# For DRF versions >=3.9.2 remove this method,
# as it already has get_search_fields built in.
return getattr(view, 'search_fields', None)
def chained_queryset_filter(self, queryset, search_terms, orm_lookups):
for search_term in search_terms:
queries = [
models.Q(**{orm_lookup: search_term})
for orm_lookup in orm_lookups
]
queryset = queryset.filter(reduce(operator.or_, queries))
return queryset
def optimized_queryset_filter(self, queryset, search_terms, orm_lookups):
conditions = []
for search_term in search_terms:
queries = [
models.Q(**{orm_lookup: search_term})
for orm_lookup in orm_lookups
]
conditions.append(reduce(operator.or_, queries))
return queryset.filter(reduce(operator.and_, conditions))
def filter_queryset(self, request, queryset, view):
search_fields = self.get_search_fields(view, request)
search_terms = self.get_search_terms(request)
if not search_fields or not search_terms:
return queryset
orm_lookups = [
self.construct_search(str(search_field))
for search_field in search_fields
]
base = queryset
if self.required_m2m_optimization(view):
queryset = self.optimized_queryset_filter(queryset, search_terms, orm_lookups)
else:
queryset = self.chained_queryset_filter(queryset, search_terms, orm_lookups)
if self.must_call_distinct(queryset, search_fields):
# Filtering against a many-to-many field requires us to
# call queryset.distinct() in order to avoid duplicate items
# in the resulting queryset.
# We try to avoid this if possible, for performance reasons.
queryset = distinct(queryset, base)
return queryset
Then, replace the filters.Searchfilter in your filter_backends with this custom class:
class EntriesListView(ListCreateAPIView):
filter_backends = (
filters.DjangoFilterBackend,
CustomSearchFilter,
...
)
use_m2m_optimization = False # this attribute control the search results
...

Django admin select m2m items based on their tags (item's m2m field)

Consider following models:
class Library(models.Model):
name = models.CharField(max_length=64)
books = models.ManyToManyField(Book)
class Book(models.Model):
name = models.CharField(max_length=64)
tags = models.ManyToManyField(Tag)
class Tag(models.Model):
name = models.CharField(max_length=64)
In the Library admin I want to add books based on their tags, while retaining the option to add/remove single books.
Existing options:
Filter_horizontal - filters by __str__, is there a way to filter by tags__name?
Raw_id_fields - works with any filters specified for Book, but you can only select 1 item. Is there a way to allow selection of more items? (checkboxes in the table)
I ended up using the Django-fsm widget and overriding its apply_filter_val method in views.py. Now I can filter for a key tag:tag_name and then just select all items. I also added an option to filter for multiple words separated by a comma.
def apply_filter_val(self, filter_val, queryset):
if filter_val and 'tag:' in filter_val:
tag_tuple = filter_val.split(':')
_, name = tag_tuple
params = {'tags__name': name}
new_base = queryset.filter(**params)
elif filter_val and ',' in filter_val:
new_base = queryset.filter(id__in=filter_val.split(','))
elif filter_val:
q = [Q(**{f'{field}__icontains': filter_val}) for field in self.fields]
if filter_val and q:
new_base = queryset.filter(reduce(self.default_operator, q))
else:
# Return everything if no filter_val or fields are specified.
# This allows for a very straightforward async request, but will
# probably not behave as expected if no fields are specified.
new_base = queryset
if self.obj_limit:
new_base = new_base[:self.obj_limit]
return new_base

Using an instance's fields to filter the choices of a manytomany selection in a Django admin view

I have a Django model with a ManyToManyField.
1) When adding a new instance of this model via admin view, I would like to not see the M2M field at all.
2) When editing an existing instance I would like to be able to select multiple options for the M2M field, but display only a subset of the M2M options, depending on another field in the model. Because of the dependence on another field's actual value, I can't just use formfield_for_manytomany
I can do both of the things using a custom ModelForm, but I can't reliably tell whether that form is being used to edit an existing model instance, or if it's being used to create a new instance. Even MyModel.objects.filter(pk=self.instance.pk).exists() in the custom ModelForm doesn't cut it. How can I accomplish this, or just tell whether the form is being displayed in an "add" or an "edit" context?
EDIT: my relevant code is as follows:
models.py
class LimitedClassForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(LimitedClassForm, self).__init__(*args, **kwargs)
if not self.instance._adding:
# Edit form
clas = self.instance
sheets_in_course = Sheet.objects.filter(course__pk=clas.course.pk)
self.Meta.exclude = ['course']
widget = self.fields['active_sheets'].widget
sheet_choices = []
for sheet in sheets_in_course:
sheet_choices.append((sheet.id, sheet.name))
widget.choices = sheet_choices
else:
# Add form
self.Meta.exclude = ['active_sheets']
class Meta:
exclude = []
admin.py
class ClassAdmin(admin.ModelAdmin):
formfield_overrides = {models.ManyToManyField: {
'widget': CheckboxSelectMultiple}, }
form = LimitedClassForm
admin.site.register(Class, ClassAdmin)
models.py
class Course(models.Model):
name = models.CharField(max_length=255)
class Sheet(models.Model):
name = models.CharField(max_length=255)
course = models.ForeignKey(Course)
file = models.FileField(upload_to=getSheetLocation)
class Class(models.model):
name = models.CharField(max_length=255)
course = models.ForeignKey(Course)
active_sheets = models.ManyToManyField(Sheet)
You can see that both Sheets and Classes have course fields. You shouldn't be able to put a sheet into active_sheets if the sheet's course doesn't match the class's course.

Access foreign key fields from Admin Tabular Inline

I'm trying to access a field of a foreign key within a Tabular Inline in the Django Admin.
Despite my best efforts I can't seem to get it working. My current code is:
class RankingInline(admin.TabularInline):
model = BestBuy.products.through
fields = ('product', 'account_type', 'rank')
readonly_fields = ('product', 'rank')
ordering = ('rank',)
extra = 0
def account_type(self, obj):
return obj.products.account_type
Which results in:
'RankingInline.fields' refers to field 'account_type' that is missing from the form.
I have also tried using the model__field method, which I used as:
fields = ('product', 'product__account_type', 'rank')
Which results in:
'RankingInline.fields' refers to field 'product__account_type' that is missing from the form.
The models are defined as so:
class Product(BaseModel):
account_type = models.CharField(choices=ACCOUNT_TYPE_OPTIONS, verbose_name='Account Type', max_length=1, default='P')
class Ranking(models.Model):
product = models.ForeignKey(Product)
bestbuy = models.ForeignKey(BestBuy)
rank = models.IntegerField(null=True, blank = True)
class BestBuy(BaseModel):
products = models.ManyToManyField(Product, through='Ranking')
class BaseModel(models.Model):
title = models.CharField(max_length = TODO_LENGTH)
slug = models.CharField(max_length = TODO_LENGTH, help_text = """The slug is a url encoded version of your title and is used to create the web address""")
created_date = models.DateTimeField(auto_now_add = True)
last_updated = models.DateTimeField(auto_now = True)
What am I doing wrong?
I think what you are looking for is nested inlines since you want to expand "Product" as inline within RankingInline. At present Django does not have such feature built in. This question is relevant: Nested inlines in the Django admin?
You can also look at "Working with many-to-many intermediary models" section in Django DOC. That might be useful.
Actually Django will show you a small green '+' button besides the inline product field entry which you can use to create a new product to assign to your current entry for BestBuy. This might be an alternative for you to use.
You simply need to add the method-field to readonly_fields:
readonly_fields = ('product', 'rank', 'account_type')
Your new field account_type should be defined in ModelAdmin (i.e. RankingAdmin) not in TabularInline (i. e. RankingInline). It should be only accessed from TabularInline.

Changed Django's primary key field, now items don't appear in the admin

I imported my (PHP) old site's database tables into Django. By default it created a bunch of primary key fields within the model (since most of them were called things like news_id instead of id).
I just renamed all the primary keys to id and removed the fields from the model. The problem then came specifically with my News model. New stuff that I add doesn't appear in the admin. When I remove the following line from my ModelAdmin, they show up:
list_display = ['headline_text', 'news_category', 'date_posted', 'is_sticky']
Specifically, it's the news_category field that causes problems. If I remove it from that list then I see my new objects. Now, when I edit those items directly (hacking the URL with the item ID) they have a valid category, likewise in the database. Here's the model definitions:
class NewsCategory(models.Model):
def __unicode__(self):
return self.cat_name
#news_category_id = models.IntegerField(primary_key=True, editable=False)
cat_name = models.CharField('Category name', max_length=75)
cat_link = models.SlugField('Category name URL slug', max_length=75, blank=True, help_text='Used in URLs, eg spb.com/news/this-is-the-url-slug/ - generated automatically by default')
class Meta:
db_table = u'news_categories'
ordering = ["cat_name"]
verbose_name_plural = "News categories"
class News(models.Model):
def __unicode__(self):
return self.headline_text
#news_id = models.IntegerField(primary_key=True, editable=False)
news_category = models.ForeignKey('NewsCategory')
writer = models.ForeignKey(Writer) # todo - automate
headline_text = models.CharField(max_length=75)
headline_link = models.SlugField('Headline URL slug', max_length=75, blank=True, help_text='Used in URLs, eg spb.com/news/this-is-the-url-slug/ - generated automatically by default')
body = models.TextField()
extra = models.TextField(blank=True)
date_posted = models.DateTimeField(auto_now_add=True)
is_sticky = models.BooleanField('Is this story featured on the homepage?', blank=True)
tags = TaggableManager(blank=True)
class Meta:
db_table = u'news'
verbose_name_plural = "News"
You can see where I've commented out the autogenerated primary key fields.
It seems like somehow Django thinks my new items don't have news_category_ids, but they definitely do. I tried editing an existing piece of news and changing the category and it worked as normal. If I run a search for one of the new items, it doesn't show up, but the bottom of the search says "1 News found", so something is going on.
Any tips gratefully received.
EDIT: here's my ModelAdmin too:
class NewsCategoryAdmin(admin.ModelAdmin):
prepopulated_fields = {"cat_link": ("cat_name",)}
list_display = ['cat_name', '_cat_count']
def _cat_count(self, obj):
return obj.news_set.count()
_cat_count.short_description = "Number of news stories"
class NewsImageInline(admin.TabularInline):
model = NewsImage
extra = 1
class NewsAdmin(admin.ModelAdmin):
prepopulated_fields = {"headline_link": ("headline_text",)}
list_display = ['headline_text', 'news_category', 'date_posted', 'is_sticky'] #breaking line
list_filter = ['news_category', 'date_posted', 'is_sticky']
search_fields = ['headline_text']
inlines = [NewsImageInline]
The answer you are looking for I think would lie in the SQL schema that you altered and not in the django models.
It could probably have something to do with null or blank values in the news_category_id, or news that belongs to a category that doesn't exist in the news_category. Things I'd check:
You have renamed the primary key on the News category from news_category_id to id. Does the foreign key on the News also map to news_category_id and not anything else?
Are all the values captured in the news.news_category also present in news_category.id
Also, as an aside, I don't see any reason why you need to rename the primary keys to id from something that they already are. Just marking them primary_key=True works just fine. Django provides you a convenient alias pk to access a model's integer primary key, irrespective of what the name of the field actually is.