Django get_queryset() trouble creating multiple annotation labels - python

I am trying to get a few different annotation results to show in the Django Admin. I need these new results to be sortable columns as well. I came up with this (after reading EXTENSIVELY on SO):
def queryset(self, request):
qs = super(BrandAdmin, self).get_queryset(request)
qs = Brand.objects.annotate(
last_connection=Max('account__computer__last_connect')
).filter(
account__computer__last_connect__gte=(datetime.datetime.now() - datetime.timedelta(weeks=4))
).annotate(
brand_count=Count('account__computer')
)
return qs
def billable_computers(self, obj):
return obj.brand_count
billable_computers.admin_order_field = 'brand_count'
def last_connection(self, obj):
return obj.last_connection
last_connection.admin_order_field = 'last_connection'
This works, but returns with really weird (multiplied by some factor) results for the 'billable_computers'. I figured this was because of the chained annotations.
So I tried this:
def queryset(self, request):
qs = super(BrandAdmin, self).get_queryset(request)
qs = Brand.objects.filter(
account__computer__last_connect__gte=(datetime.datetime.now() - datetime.timedelta(weeks=4))
).all().distinct().annotate(
brand_count=Count('account__computer')
)
qss = super(BrandAdmin, self).get_queryset(request)
qss = Brand.objects.annotate(
last_connection=Max('account__computer__last_connect')
)
qs_all = qs | qss
return qs_all
def billable_computers(self, obj):
return obj.brand_count
billable_computers.admin_order_field = 'brand_count'
def last_connection(self, obj):
return obj.last_connection
last_connection.admin_order_field = 'last_connection'
But this causes an error complaining that "'Brand' object has no attribute 'last_connection'".
So I tried this:
def queryset(self, request):
qs = super(BrandAdmin, self).get_queryset(request)
qs = Brand.objects.filter(
account__computer__last_connect__gte=(datetime.datetime.now() - datetime.timedelta(weeks=4))
).annotate(
brand_count=Count('account__computer')
)
qs = Brand.objects.annotate(
last_connection=Max('account__computer__last_connect')
)
return qs
But this give a "'Brand' object has no attribute 'brand_count'" error.
About the models: The Computer model has a foreign key to Account and Account has a foreign key to Brand. The Computer model has a datetime field labeled 'last_connect'. Every Brand can have unlimited Accounts and every Account can have unlimited Computers.
I just need to tally all the Computers (at each level) that have connected in the last 4 weeks. I also need a separate (sortable) column for the latest 'last_connect' value across all computers in the unfiltered set.
Obviously I am getting this wrong, but it seems like it should be so simple. I need two different metrics. I want to query them in the Admin and not at the model level. I don't want to make my models any more complicated than they are now.
Any advice is welcome! Thank you!

Related

How to show only one object in django admin list view?

Problem
I wish to show only the last row of a QuerySet based on the ModelAdmin's ordering criteria. I have tried a couple of methods, but none has worked for me.
Model:
class DefaultConfig(models.Model):
created_at = models.DateTimeField()
...
Attempt 1:
I tried overriding the ModelAdmin's get_queryset method and slicing super's result, but I came up with some issues.
I.E:
class DefaultConfigAdmin(models.ModelAdmin):
model = Config
ordering = ('-created_at',)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs[<slice>]
I tried the following values for [<slice>]s:
[-1:]: raised an Exception because negative slicing is not supported
[:1]: raised AssertionError: Cannot reorder a query once a slice has been taken.
Attempt 2:
I tried obtaining the max value for created_at and then filtering for records with that value. I.E:
class DefaultConfigAdmin(models.ModelAdmin):
model = Config
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.annotate(max_created_at=Max('created_at')).filter(created_at=F('max_created_at'))
But silly me, that works at row level, so it will only return aggregates over the row itself.
Further attempts (TBD):
Perhaps the answer lies in SubQuerys or Windowing and ranking.
Is there a more straight forward way to achieve this though?
Did you try to set list_per_page = 1?
class DefaultConfigAdmin(models.ModelAdmin):
model = Config
ordering = ('-created_at',)
list_per_page = 1
Technically this will still return all Config objects, but only one per page and the latest one will be on the first page.
Another solution (similar to your "Attempt 2"), which involves an extra query, is to manually get hold of the latest created_at timestamp and then use it for filtering.
class DefaultConfigAdmin(models.ModelAdmin):
model = Config
def get_queryset(self, request):
qs = super().get_queryset(request)
latest_config = qs.order_by('created_at').last()
return qs.filter(created_at=latest_config.created_at)

Ordering by computed field in admin panel

I'm learning Django and I got stuck on this problem
I'm basically trying to order by a computed field in an admin panel view, I've read some "tutorials" like this: https://books.agiliq.com/projects/django-admin-cookbook/en/latest/sorting_calculated_fields.html on google but I can't seem to figure out how it all works (between annotations etc)
Here's my classes:
class StockAdmin(admin.ModelAdmin):
list_display = ("ticker_symbol", "security_name", "current_pretax_yield", "current_aftertax_yield", "displayed_price")
search_fields = ("ticker_symbol", "security_name")
def current_pretax_yield(self, obj):
try:
curyield = float(obj.coupon_amount/obj.last_price)
return str(round((curyield*100),3)) + "%"
except:
return "n/a"
def current_aftertax_yield(self, obj):
try:
withholding_tax = 15
at_yield = ((obj.coupon_amount/obj.last_price*100)*(1-(withholding_tax/100))*0.74)
return str(round(at_yield, 2)) + "%"
except:
return "n/a"
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(
_current_aftertax_yield=self.current_aftertax_yield(),
_current_pretax_yield=self.current_pretax_yield(),
)
current_aftertax_yield.admin_order_field = '_current_aftertax_yield'
current_pretax_yield.admin_order_field = '_current_pretax_yield'
Basically, I want to get "coupon amount" and "last price" fields from the database, perform the calculations you see in the functions, then display those calculated files in the admin panel and be able to "order by" them
The code as I have now errors out with a TypeError: current_aftertax_yield() missing 1 required positional argument: 'obj'
I've tried to follow this: https://books.agiliq.com/projects/django-admin-cookbook/en/latest/sorting_calculated_fields.html but I can't quite figure it out on my own..
Any ideas? Is there an easier way of doing this? I used a lot of computed values in PHP and it was trivial to implement!
You can not use methods to annotate a Queryset. You should specify an expression constructed an expression for the database. You can not use a method for that. You can make use of combinations of F-expressions together with certain aggregates, etc.
Here both the current_pretax_yield and current_aftertax_yield scale with coupon_amount/last_price, so we can make an annotation, and then sort by that annotation:
from django.db.models import F
class StockAdmin(admin.ModelAdmin):
list_display = ("ticker_symbol", "security_name", "current_pretax_yield", "current_aftertax_yield", "displayed_price")
search_fields = ("ticker_symbol", "security_name")
def current_pretax_yield(self, obj):
try:
curyield = float(obj.coupon_amount/obj.last_price)
return str(round((curyield*100),3)) + "%"
except:
return "n/a"
def current_aftertax_yield(self, obj):
try:
withholding_tax = 15
at_yield = ((obj.coupon_amount/obj.last_price*100)*(1-(withholding_tax/100))*0.74)
return str(round(at_yield, 2)) + "%"
except:
return "n/a"
def get_queryset(self, request):
return super().get_queryset(request).annotate(
_yield=F('coupon_amount')/F('last_price')
)
current_aftertax_yield.admin_order_field = '_yield'
current_pretax_yield.admin_order_field = '_yield'

Django - Add object in front of existing QuerySet

I am building a library Django application where I have a BooksView to display all Books written by a certain Author. Under certain conditions, I want a single Book to be displayed before all other books in the query set. This should happen when the id of the book appears in the URL, otherwise, all books should be listed in chronological order.
books/urls.py:
urlpatterns = [
# Books displayed in chronological order
path("<slug:author>/books", views.BooksView.as_view()),
# Books displayed in chronological order
# but first book is the one with id `book`
path("<slug:author>/books/<int:book>", views.BooksView.as_view()),
]
books/views.py:
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
books = author.author_books
books = books.order_by("-pub_date")
if book:
book = get_object_or_404(Book, id=book)
# TODO: add `book` in front of books QuerySet (and remove duplicated if any)
serializer = BooksSerializer(books, many=True)
return Response(serializer.data)
How can I accomplish my TODO? How can I push an object in front of an existing query set?
This doesn't precisely answer your question but I think it would allow you to get the desired outcome: don't worry about preserving the queryset and instead transform it into a list.
This isn't a thing you want to do for every queryset in your entire codebase especially if it's a queryset that you might later want to build upon and compose with other filters, excludes, etc. But when you're about to render a page (like you are here) it is OK because you're about to cause the queryset to be evaluated anyhow. You're causing it to happen perhaps microseconds sooner.
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
books = list(author.author_books)
if book:
book = Book.objects.get(id=book)
# TODO: add `book` in front of books QuerySet (and remove duplicated if any)
books = [x for x in books if not x == book] # first remove if dup
books.insert(0, book) # now insert at front
serializer = BooksSerializer(books, many=True)
return Response(serializer.data)
EDIT 1
The BooksSerializer (which subclasses the BaseSerializer I suspect) is going to make a list anyhow as soon as you call it:
def to_representation(self, data):
"""
List of object instances -> List of dicts of primitive datatypes.
"""
# Dealing with nested relationships, data can be a Manager,
# so, first get a queryset from the Manager if needed
iterable = data.all() if isinstance(data, models.Manager) else data
return [
self.child.to_representation(item) for item in iterable
]
https://github.com/encode/django-rest-framework/blob/master/rest_framework/serializers.py#L663
EDIT 2
What about trying this instead? By adding the exclude before the queryset is evaluated into a list you prevent the O(n) scan through the list to find and remove the "main" book that's supposed to be at the top.
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
books = author.author_books
if book:
book = Book.objects.get(id=book)
# TODO: add `book` in front of books QuerySet (and remove duplicated if any)
# ensure the queryset doesn't include the "main" book
books = books.exclude(book_id=book.id)
# evaluate it into a list just like the BookSerializer will anyhow
books = list(books)
# now insert the "main" book at the front of the list
books.insert(0, book)
serializer = BooksSerializer(books, many=True)
return Response(serializer.data)
You can use the bitwise | operator as
from django.http.response import Http404
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
book_qs = author.author_books
if book:
single_book_qs = Book.objects.filter(id=book)
if not single_book_qs.exists():
raise Http404
book_qs = single_book_qs | book_qs
serializer = BooksSerializer(book_qs, many=True)
return Response(serializer.data)
Please note that one caveat of this solution is that, if you use the order_by(...) method, the position will be changed according to the ORDER By expression.
Update 1
Since you are using an order_by(...) expression, you must do something like this,
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
book_qs = author.author_books.order_by("-pub_date")
serialized_single_book = []
if book:
single_book = get_object_or_404(Book, id=book)
book_qs.exclude(id=book) # to remove dups
serialized_single_book = [BooksSerializer(single_book).data]
serializer = BooksSerializer(book_qs, many=True)
serialized_book_qs = serializer.data
return Response([*serialized_single_book, *serialized_book_qs])
This should do it, just use the union method in the QuerySet object, and exclude
the book we are trying to access from books queryset.
I did some minor changes to the code, but you don't need to do more than that, to accomplish what you need.
class BooksView(APIView):
def get(self, request, author, book=None):
author = get_object_or_404(Author, slug=author)
books = author.author_books.all().order_by('-pub_date')
if book:
# I am assuming this line if for validating that the request is valid.
# I've changed the query to also include author's slug,
# so the request doesn't get books not related to the author.
book_obj = get_object_or_404(Book, id=book, author__slug=author)
# Just Query the same book, and union it with the books queryset,
# excluding the current book in the book_obj
books = Book.objects.filter(id=book_obj.id).union(books.exclude(id=book_obj.id))
serializer = BooksSerializer(books, many=True)
return Response(serializer.data)
A little out of the box maybe, but if you want to keep it as queryset, then this is the solution. Please note that this solves the X problem, not the Y problem. The goal is to put one book first in the list, which can be achieved by insertion, but also by reordering based on an annotation.
from django.db.models import Case, When, Value, IntegerField
from rest_framework import generics
from . import models, serializers
class BooksPerAuthor(generics.ListAPIView):
serializer_class = serializers.BookWithoutAuthor
def get_queryset(self):
book_pk = self.kwargs.get("book", 0)
queryset = (
models.Book.objects.filter(author__slug=self.kwargs["author"])
.annotate(
promoted=Case(
When(pk=book_pk, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
)
)
.order_by("-promoted", "-pub_date")
)
return queryset
This doesn't catch non-existing references - not a fan of returning 404 for list views and in case the promoted book is not in the collection, then there's no real harm done. Plenty of examples above to do the same thing with 404's at the (minor) cost of additional queries.

OR logic with Django-filter

I'm using Django-filter to allow the user to filter a database based on multiple choices in two fields. The filterset.py looks like this:
class TapFilter(django_filters.FilterSet):
bar__region = django_filters.MultipleChoiceFilter(choices=CHOICES, label="Regions:", widget=forms.CheckboxSelectMultiple,help_text="")
bar = django_filters.ModelMultipleChoiceFilter(queryset=Bar.objects.all(), label="Bars:", widget=forms.CheckboxSelectMultiple,help_text="")
However, this functions as an AND between the two lists. I need OR instead. That is, I need to show anything matching the selection in either category.
I have seen similar questions using normal filters, but I would prefer to keep using django-filter if possible.
The website in question is here: http://bestap.pythonanywhere.com/
Update: I've put this in my filtersets.py, but am clearly not doing things right...
class TapFilter(django_filters.FilterSet):
bar__region = django_filters.MultipleChoiceFilter(choices=CHOICES, label="Regions:", widget=forms.CheckboxSelectMultiple,help_text="")
bar = django_filters.ModelMultipleChoiceFilter(queryset=Bar.objects.all(), label="Bars:", widget=forms.CheckboxSelectMultiple,help_text="")
def qs(self):
base_qs = Bar.objects.all()
qs = Bar.objects.none()
for name, filter_ in six.iteritems(self.filters):
value = self.form.cleaned_data[name]
qs = qs | filter_.filter(base_qs, value)
return qs
This gives me the error 'function' object has no attribute 'count'.
You'll need to override qs on your TapFilter FilterSet subclass.
The base implementation is not that complicated; the essence of it loops over the filters applying them to the queryset.
Simplified:
for name, filter_ in six.iteritems(self.filters):
value = self.form.cleaned_data[name]
qs = filter_.filter(qs, value)
You need the union of the filters' QuerySets, which you can get because QuerySet implements __or__, so (again simplified) you'll need something like:
base_qs = Bar.objects.all()
qs = Bar.objects.none()
for name, filter_ in six.iteritems(self.filters):
value = self.form.cleaned_data[name]
qs = qs | filter_.filter(base_qs, value)
Hopefully that gets you started.

How to sort by many custom methods in Django Admin

I want to be able to sort by several custom methods in Django Admin. This question provides solution for one method only.
I tried to modify it:
from django.db import models
class CustomerAdmin(admin.ModelAdmin):
list_display = ('number_of_orders','number_of_somevalue') # added field
def queryset(self, request):
qs = super(CustomerAdmin, self).queryset(request)
qs = qs.annotate(models.Count('order'))
qs = qs.annotate(models.Count('somevalue')) # added line
return qs
def number_of_orders(self, obj):
return obj.order__count
number_of_orders.admin_order_field = 'order__count'
def number_of_somevalue(self, obj): # added method
return obj.somevalue__count
number_of_somevalue.admin_order_field = 'somevalue__count'
and it works incorrectly. It seems that it multiplies the count values instead of counting them separately.
Example:
I have 2 orders and 2 somevalues, but in the panel I see 4 orders and 4 somevalues.
Adding another method with yet another value makes it 8 (2*2*2).
How can I fix it?
You can try this to sort by many custom methods (Tested):
from django.db.models import Count
class CustomerAdmin(admin.ModelAdmin):
# The list display must contain the functions that calculate values
list_display = ('number_of_orders','number_of_somevalue') # added field
# Overwrite queryset in model admin
def queryset(self, request):
qs = super(CustomerAdmin, self).queryset(request)
# The query have to return multiple annotation, for this use distinct=True in the Count function
qs = qs.annotate(number_orders = Count('order', distinct=True)).annotate(number_somevalue = Count('somevalue',distinct=True))
return qs
# This function return the new field calculated in queryset (number_orders)
def number_of_orders(self, obj):
return obj.number_orders
number_of_orders.admin_order_field = 'numberorders' # sortable new column
# And this one will return the another field calculated (number_somevalue)
def number_of_somevalue(self, obj): # added method
return obj.number_somevalue
number_of_somevalue.admin_order_field = 'number_somevalue'# sortable new column

Categories

Resources