自動化無しに生活無し

WEBとかAIとかLinux関係をひたすら書く備忘録系ブログ

【Django】ManyToManyFieldで検索をする方法、追加・削除を行う方法【多対多はクエリビルダの検索は通用しない】

thumbnail

背景

TopicとTagの多対多のリレーションを組んだ状況で。

# この場合、1を含んでいるという意味になる
data = Topic.objects.filter(tag=1)
print(data)

# この場合、1もしくは3を含むデータが手に入るが、重複する。.distinct() で除外できる。
# しかし1だけ、3だけのデータも取得できてしまう。ほしいのは1と3を含むデータ
data = Topic.objects.filter(tag__in=[1,3]).distinct()
print(data)

複数のタグ検索が正常に機能してくれない状況にある。(1だけ、3だけではなく、1と3を含んだデータのみがほしい)

仮にクエリビルダを使っても、

# この場合、1と3を含むデータ、1と3のみ指定しているデータいずれも取り出せない。
test_query = Q()
test_query &= Q(tag=1)
test_query &= Q(tag=3)
data = Topic.objects.filter(test_query)
print(data)

# 2番目の方法と同様?
test_query = Q()
test_query |= Q(tag=1)
test_query |= Q(tag=3)
data = Topic.objects.filter(test_query).distinct()
print(data)

タグ検索をするとき、1つだけタグを指定して検索をするのであれば、1番目の方法が妥当だ。

しかし、複数のタグを検索し、そのタグを含むTopicを検索するケースが多い。

複数のタグ検索に対応した方法を解説する。

多対多の検索をする

テンプレート

まずは、テンプレート。検索フォームを作る。チェックボックスを使う。

<form action="" method="get">
    <div>
        {# TODO:チェックしたタグであるかを判定するには、カスタムテンプレートタグを使うしかない #}
        {# 参照: https://stackoverflow.com/questions/34050150/django-templates-get-list-of-multiple-get-param#}
        {# TODO: この場合はcontextプロセッサーを使うと言う方法も考えられる。 #}
        {# https://noauto-nolife.com/post/django-context-processors/ #}

        {% for tag in tags %}
        <label><input type="checkbox" name="tag" value="{{ tag.id }}" {% tag_checked request tag.id %}>:{{ tag.name }}</label>
        {% endfor %}
    </div>

    <div class="input-group">
        <input class="form-control" type="text" name="search" placeholder="キーワード検索">
        <div class="input-group-append">
            <input class="form-control btn btn-outline-primary" type="submit" value="検索">
        </div>
    </div>
</form>

チェックを入れて多対多の検索時、チェックを入れたinputタグに関してはcheckedを付与する必要が有る。

ただ、それを実現するには、contextプロセッサーかカスタムテンプレートタグしかない。テンプレートタグでは再現できない。

ビュー

def get(self, request, *args, **kwargs):

    context = {}
    context["tags"]     = Tag.objects.all()

    query   = Q() 

    if "search" in request.GET:
        search      = request.GET["search"]

        raw_words   = search.replace(" "," ").split(" ")
        words       = [ w for w in raw_words if w != "" ]

        for w in words:
            query &= Q(title__contains=w)


    #ここで一旦queryによる検索を行う
    topics  = Topic.objects.filter(query).order_by("-dt")


    #TODO:タグの検索(指定されたタグが実在するのか確認をする。)
    form    = TopicTagForm(request.GET)

    if form.is_valid():
        cleaned         = form.clean()
        selected_tags   = cleaned["tag"] 

        #タグ検索をする(中間テーブル未使用、指定したタグを全て含む)
        for tag in selected_tags:

            #TODO:指定したタグが、トピックに含まれているかをチェック。含まれていれば追加。
            topics      = [ topic for topic in topics if tag in topic.tag.all() ]

    context["topics"]   = topics

    return render(request,"bbs/index.html",context)

多対多の検索をする時クエリビルダを使用しても、想定した通りの結果が得られないことに注意。

チェックを入れた多対多で検索をする際は、一旦クエリビルダを発動させた上で、そこから絞り込みをかけると良いだろう。

カスタムテンプレートタグ

検索した時、チェックした要素にcheckedを付与するには、カスタムテンプレートタグ辺りしか方法はない。テンプレート上でrequest.GET.getlist("tag")などと呼び出すことはできないためである。

#https://noauto-nolife.com/post/django-paginator/

from django import template
register = template.Library()

#検索時に指定したタグとモデルオブジェクトのtagのidが一致した場合はchecked文字列を返す。
#カスタムテンプレートタグとして機能させるため、.simple_tag()デコレータを付与する
@register.simple_tag()
def tag_checked(request, tag_id):

    #検索時に指定したタグのid(文字列型)のリストを作る
    tags    = request.GET.getlist("tag")

    #tagsにidが含まれる場合
    if str(tag_id) in tags:
        return "checked"

【検索】動かすとこうなる

多対多の追加・削除をする

主に良いねをする際、良いねを削除する際などに使う事ができる

今回はタグを追加・削除する際に利用した。

ビュー

class AddTagView(LoginRequiredMixin,View):

    def post(self, request, pk, *args, **kwargs):

        topic   = Topic.objects.filter(id=pk).first()
        form    = TopicTagForm(request.POST)

        if form.is_valid():

            cleaned = form.clean()

            selected_tags   = cleaned["tag"] 

            #タグ検索をする(中間テーブル未使用、指定したタグを全て含む)
            for tag in selected_tags:

                #このtagはTagモデルクラスのオブジェクト。追加する時はこうする。save()は実行しなくても良い
                #https://stackoverflow.com/questions/1182380/how-to-add-data-into-manytomany-field

                if tag in topic.tag.all():
                    topic.tag.remove(tag)
                else:
                    topic.tag.add(tag)

        return redirect("bbs:index")

tag     = AddTagView.as_view()

多対多のフィールドに対して、.remove(),.add()のメソッドをそれぞれ実行すればよい。引数はモデルオブジェクトを与え、そのモデルオブジェクトを追加、削除する

フォーム

ちなみに、このTagFormは前項の検索の際にも使った。

多対多のリレーションを守るため、存在確認をするためにも、下記フォームクラスを通してバリデーション、型変換を行わなければならない。

class TopicTagForm(forms.ModelForm):

    class Meta:
        model   = Topic
        fields  = [ "tag" ]

テンプレート

{% for topic in topics %}
<div class="border">

    <h2>{{ topic.title }}</h2>

    <div>タグ: {% for tag in topic.tag.all %}{{ tag }} {% endfor %}</div>
    <div>{{ topic.dt }}</div>

    <div>投稿者:{{ topic.user }}</div>
    <div>{{ topic.comment|linebreaksbr }}</div>

    {# 多対多に対して、後から追加する。 #}

    {# TODO:ManyToManyの追加 (良いねも同様の機能で実装できる。) #}

    <h2>タグ追加</h2>

    {% for tag in tags %}
    <form action="{% url 'bbs:tag' topic.id %}" method="POST" style="display:inline-block;">
        {% csrf_token %}
        <input type="hidden" name="tag" value="{{ tag.id }}">
        <input type="submit" value="{{ tag.name }}">
    </form>
    {% endfor %}

</div>
{% endfor %}

タグのボタンをクリックした時、そのタグが追加される。

これをいいねボタンにしたい場合は、良いねボタンに変え、.add()に与える引数はユーザーモデルのオブジェクトにすると良い。

結論

Djangoの多対多は挙動や使いどころが他のフィールドと異なる点が多いようだ。

管理サイトでデータを操作しつつ、挙動を確かめたいところだ。

ソースコード

https://github.com/seiya0723/bbs_m2m_search_and_add

スポンサーリンク

シェアボタン

Twitter LINEで送る Facebook はてなブログ