How to mix two queries to one as dropdown elements

Viewed 322

I need to join and pass two queries of category and subcategory as elements of an autocomplete select2 dropdown as my django form field as below:

enter image description here

This is my form:

class CategoriesAutocomplete(autocomplete.Select2QuerySetView):
    def get_queryset(self):
       if not self.request.user.is_authenticated:
           return Categories.objects.none()
       qs = Categories.objects.all()
       if self.q:
           qs = qs.filter(Q(name__icontains=self.q))
       return qs

   def get_result_label(self, item):
       return format_html( item.name)


class categories_form(forms.ModelForm):
    categories = forms.ModelChoiceField(
        queryset= Categories.objects.none(),
        widget= autocomplete.ModelSelect2(
            url='load_categories',
            attrs={
                    'data-placeholder': 'Select a category',
                    'data-html': True,
                    'style': 'min-width: 15em !important;',
                }
            )
    )

    class Meta:
        model = Post
        fields = ['categories']

    def __init__(self, *args, **kwargs):
        super(category_form, self).__init__(*args, **kwargs)
        self.fields['categories'].queryset = Categories.objects.all()

and in url:

path('ajax/categories-autocomplete', CategoriesAutocomplete.as_view(), name='load_categories'),

for category model:

class Categories(models.Model):
    name = models.CharField(max_length=100)
    brief = models.TextField(null=True)
    slug = models.SlugField(max_length=200)
    date = models.DateTimeField(default=timezone.now)

    class Meta:
        db_table = "categories"

for sub-category model:

class Sub_Categories(models.Model):
    name = models.CharField(max_length=100)
    brief = models.TextField(null=True)
    slug = models.SlugField(max_length=200)
    date = models.DateTimeField(default=timezone.now)

    class Meta:
        db_table = "sub_categories"

model to connect category with sub-categories:

class Categories_Sub_Categories(models.Model):
    category = models.OneToOneField(Categories, primary_key=True, on_delete=models.CASCADE)
    sub_cat = models.OneToOneField(Sub_Categories, on_delete=models.CASCADE)

    class Meta:
        db_table = "categories_sub-categories"

and in Post model:

class Post(models.Model):
    title = models.CharField(max_length=50)
    descript = HTMLField(blank=True, null=True)
    categories = models.ForeignKey(Categories, blank=True, null=True, on_delete=models.CASCADE)
    subcategories = models.ForeignKey(Sub_Categories, blank=True, null=True, on_delete=models.CASCADE)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    date = models.DateTimeField(default=timezone.now)

UPDATE:

The answer provided by @ha-neul is working just there is a bug. I show it with an example:

This is what expect in the dropdown:

**Asia**
   China
   Malaysia
   India
   Tajikistan
   Iran
   Qatar

**Europe**
   Germany
   Italy
   Spain
   Netherlands
   France

**Africa**
  Gana
  ...

But this is what I see:

    **Asia**
       China
       Malaysia
    
    **Europe**
       Netherlands
       France
       Sweden
       Norway

   **Asia**
     India
     Tajikistan
     Iran



  **Europe**
     Germany
     Italy

  **Asia**
    Qatar

  **Africa**
     Gana
     ...

 **America**
   ....

**Europe**
   Spain

in the SubCategory table I have something like:

 id ........... category_id
 1                1
 2                1
 3                1
 4                1
 5                3
 6                1
 7                2

I am following this package. Any idea to make me even closer to the solution would be appreciated!!

3 Answers

You do not need to create this model to make a relation

Categories_Sub_Categories

just create a one-to-many field (categories) in Sub_Categories model and put Categories model there (foreign), it will do that automatically, then retrieve data like this (in backend)

categories = Categories.objects.all()

you will get all categories with Sub_Categories object here, pass it to frontend and loop through it (in front-end)

for category in categories:
        sub_categories = category.sub_categories_set.all()

If this is what you want to achieve, then:

enter image description here

The short answer is you should

  1. have your subcategory with ForeignKeyField referring to Category model.
  2. use Select2GroupQuerySetView instead of Select2QuerySetView.

But implementing it is a bit complicated.

First of all, although django-autocomplete-light's source code has Select2GroupQuerySetView , somehow you cannot just use it as autocomplete.Select2GroupQuerySetView. So, you have to write the same thing in your own views.py. In addition, the source code has a typo, so you need to fix it.

Step 1. In models.py:

class Category(models.Model):
    name = models.CharField(max_length=100)
    brief = models.TextField(null=True)
    slug = models.SlugField(max_length=200)
    date = models.DateTimeField(default=timezone.now)

class SubCategory(models.Model):

    ##################
    #You need add this line, so there is a one-to-many relationship

    category = models.ForeignKey(Category, on_delete=models.CASCADE,
        related_name='subcategories')

    ###############

    name = models.CharField(max_length=100)
    brief = models.TextField(null=True)
    slug = models.SlugField(max_length=200)
    date = models.DateTimeField(default=timezone.now)

Step2.in views.py copy-paste Select2GroupQuerySetView code and fix a typo

# import collections, so Select2GroupQuerySetView can work

import collections  

class Select2GroupQuerySetView(autocomplete.Select2QuerySetView):

    group_by_related = None
    related_field_name = 'name'

    def get_results(self, context):

        if not self.group_by_related:
            raise ImproperlyConfigured("Missing group_by_related.")

        groups = collections.OrderedDict()

        object_list = context['object_list']

        print(object_list)
        object_list = object_list.annotate(
            group_name=F(f'{self.group_by_related}__{self.related_field_name}'))

        for result in object_list:
            group_name = getattr(result, 'group_name')
            groups.setdefault(group_name, [])
            groups[group_name].append(result)

        return [{
            'id': None,
            'text': group,
            'children': [{
                'id': result.id,
                'text': getattr(result, self.related_field_name),

                # this is the line I had to comment out
                #'title': result.descricao 
            } for result in results]
        } for group, results in groups.items()]

3. write your own view using Select2GroupQuerySetView

class SubCategoryAutocomplete(Select2GroupQuerySetView):
    print('under subcategory autocomplete')

    group_by_related = 'category'   # this is the fieldname of ForeignKey
    related_field_name = 'name'     # this is the fieldname that you want to show.

    def get_queryset(self):
        ##### Here is what you normally put... I am showing the minimum code. 

        qs = SubCategory.objects.all() 

        if self.q:
            qs = qs.filter(name__istartswith=self.q)

        return qs

Howe to use this view in a your project?

1. Say you have a Post model as below, with subcategory as a ForeignKey.

class Post(models.Model):
    title = models.CharField(max_length=100)
    
    subcategory = models.ForeignKey(SubCategory,on_delete=models.CASCADE)

    def __str__(self):
        return self.title

2. You will generate a PostForm that contains the subcategory autocomplete field.

in forms.py

from django.conf.urls import url

class PostForm(forms.ModelForm):

    subcategory = forms.ModelChoiceField(
        queryset=Subcategory.objects.all(),

        widget=autocomplete.ModelSelect2(url='subcategory-autocomplete')
    )

    class Meta:
        model= Post
        fields = ('title','subcategory')

  1. You will generate a CreatePostView using generic CreateView
from django.views.generic.edit import CreateView

class CreatePostView(CreateView):
    model=Post
    template_name='yourapp/yourtemplate.html'# need to change

    form_class=PostForm
    success_url = '/'
  1. Now, in your urls.py, one url for CreatePostView another one for autocomplete view.
urlpatterns = [
    url(
        r'^subcategory-autocomplete/$',
        SubCategoryAutocomplete.as_view(),
        name='subcategory-autocomplete',
    ),
    path('post/create',CreatePostView.as_view(), name='create_post'),
  1. it's all set, you will go to post/create and see a PostForm with subcategories autocomplete field.

  2. OP had a weird grouping behavior after using the code above. In his comment, he mentioned:

Added a .order_by(category_id')` to the qs and fixed it.

To make SubCategory you can have ForeignKey to self.

Another point, you'll need to use prefetch_related from main model (Post) to be able to "join" Category/SubCategory there.

Here is an example how this should look like:

# forms.py
from django import forms
from django.db.models import Prefetch

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = [...]

    def __init__(self, *args, **kwargs):
        super(PostForm, self).__init__(*args, **kwargs)
        cats = Category.objects \
            .filter(category__isnull=True) \
            .order_by('order') \
            .prefetch_related(Prefetch('subcategories',
                queryset=Category.objects.order_by('order')))
        self.fields['subcategory'].choices = \
            [("", self.fields['subcategory'].empty_label)] \
            + [(c.name, [
                (self.fields['subcategory'].prepare_value(sc),
                    self.fields['subcategory'].label_from_instance(sc))
                for sc in c.subcategories.all()
            ]) for c in cats]


# models.py
class Category(models.Model):
    category = models.ForeignKey('self', null=True, on_delete=models.CASCADE,
        related_name='subcategories', related_query_name='subcategory')

class Post(models.Model):
    subcategory = models.ForeignKey(Category, on_delete=models.CASCADE,
        related_name='posts', related_query_name='post')

Related