djangounit-testingmany-to-manymultiple-select

Django - How to unit test view managing M2M relationship


I have a view that implements a m2m relationship, and I would like to unit test it, which I did not manage yet. The view seems working with the page I defined, but any suggestion is also welcome.
The context is the following: I would like to manage users' groups in a Django app, and, of course, as I need additional fields, I built a model dedicated to user's management in my app. I defined a page with to multiple select boxes, one for the list of users, the other one with users selected to be part of the group. Inbetween are action icons to move users from one group to the others. At the stage, there is no control if a users shall not belong to more than one group, all users that do not belong to the current group are displayed (I assume it's just a question of filtering data).
My page currently looks like this (btw, if you have any suggestion to make titles displayed above the selection boxes, I would also appreciate, even if it's not the topic here.
enter image description here

I would like to unit test the contain of each group and, later on, the impact of adding or removing a user from group.
At this stage, I was just able to check a user in the database is displayed, but actually I have no clue if it is part of one group or another. If I add some rules, such as verifying the user is not already part of a group (or do not propose it to be added to the group in this case), I need to build more precise test, and I do not know yet how to do.

Here is my current working test code:

class TestAdmGroups(TestCase):
    def setUp(self):
        self.company = create_dummy_company("Société de test")

        self.group1 = EventGroup.create_group({"company": self.company, "group_name": "Groupe 1", "weight": 40})
        self.group2 = EventGroup.create_group({"company": self.company, "group_name": "Groupe 2", "weight": 60})
        # self.group2 = EventGroup.objects.create(company=self.company, group_name="Groupe 2", weight=60)

        self.user_staff = create_dummy_user(self.company, "staff", group=self.group1, admin=True)
        self.usr11 = create_dummy_user(self.company, "user11", group=self.group1)
        self.usr12 = create_dummy_user(self.company, "user12", group=self.group1, admin=True)
        self.usr13 = create_dummy_user(self.company, "user13")
        self.usr14 = create_dummy_user(self.company, "user14")
        self.usr21 = create_dummy_user(self.company, "user21", group=self.group2)
        self.usr22 = create_dummy_user(self.company, "user22", group=self.group2)

    def test_adm_update_group(self):
        self.client.force_login(self.user_staff.user)
        url = reverse("polls:adm_group_detail", args=[self.company.comp_slug, self.group1.id])
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "user11")
        self.assertContains(response, "user14")
        self.assertContains(response, "user21")

I would like to separate results, and ensure user11 is part of the right list, the other values are supposed to be part of the left one.

The view is the following:

def adm_group_detail(request, comp_slug, grp_id=0):
    company = Company.get_company(comp_slug)
    if grp_id > 0:
        current_group = EventGroup.objects.get(id=grp_id)
        group_form = GroupDetail(request.POST or None, instance=current_group)
    else:
        group_form = GroupDetail(request.POST or None)
        group_form.fields['all_users'].queryset = UserComp.objects.\
                                                    filter(company=company).\
                                                    order_by('user__last_name', 'user__first_name')


    if request.method == 'POST':
        # Convert the string in a list of user IDs
        usr_list = [int(elt) for elt in request.POST['group_list'].split('-') if elt != ""]

        group_form.fields['users'].queryset = UserComp.objects.filter(id__in=usr_list).\
                                                        order_by('user__last_name', 'user__first_name')
        group_form.fields['all_users'].queryset = UserComp.objects.exclude(id__in=usr_list)

        if group_form.is_valid():
            if grp_id == 0:
                # Create empty group
                group_data = {
                    "company": company,
                    "group_name": group_form.cleaned_data["group_name"],
                    "weight": group_form.cleaned_data["weight"],
                }
                new_group = EventGroup.create_group(group_data)
            else:
                # Update group
                new_group = group_form.save()

                # Remove all users
                group_usr_list = UserComp.objects.filter(eventgroup=new_group)
                for usr in group_usr_list:
                    new_group.users.remove(usr)

            # Common part for create and update : add users according to new/updated list
            for usr in usr_list:
                new_group.users.add(usr)
            new_group.save()

            # Update form according to latest changes
            group_form.fields['all_users'].queryset = UserComp.objects.\
                                                            exclude(id__in=usr_list).\
                                                            order_by('user__last_name', 'user__first_name')
            group_form.fields['group_list'].initial = "-".join([str(elt.id) for elt in new_group.users.all()])

    return render(request, "polls/adm_group_detail.html", locals())

I managed to make the view work with both lists being part of the same form, but I can change this if you have any suggestion.
With this view, I noticed that I could get values from one list or another the following way: response.context["group_form"]["all_users"] or response.context["group_form"]["users"] but unfortunately, it looks like it's not possible to enter one of these values as parameter of assertContains() (self.assertContains(response.context["group_form"]["users"], self.user11.user.username) does not work, as the fist parameter is supposed to be a response) nor assertInHTML(), in this case I have the following error message with the same previous parameters:

======================================================================
ERROR: test_adm_update_group (polls.tests_admin.TestAdmGroups)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\Mes documents\Informatique\Developpement\Votes AG\projet_votes\polls\tests_admin.py", line 264, in test_adm_update_group
    self.assertInHTML(response.context["group_form"]["users"], self.usr11.user.username)
  File "C:\Users\Christophe\.virtualenvs\projet_votes-onIieQ0I\lib\site-packages\django\test\testcases.py", line 791, in assertInHTML        
    needle = assert_and_parse_html(self, needle, None, 'First argument is not valid HTML:')
  File "C:\Users\Christophe\.virtualenvs\projet_votes-onIieQ0I\lib\site-packages\django\test\testcases.py", line 62, in assert_and_parse_html
    dom = parse_html(html)
  File "C:\Users\Christophe\.virtualenvs\projet_votes-onIieQ0I\lib\site-packages\django\test\html.py", line 220, in parse_html
    parser.feed(html)
  File "c:\program files\python37\Lib\html\parser.py", line 110, in feed
    self.rawdata = self.rawdata + data
TypeError: can only concatenate str (not "BoundField") to str

----------------------------------------------------------------------

As you can see in the screenshot, I would like to check a user is in one list or another, not only displayed on the page like I did yet.

Here is the model's definition:

class EventGroup(models.Model):
    """
    Groups of users
    The link with events is supported by the Event
    (as groups can be reused in several Events)
    """
    company = models.ForeignKey(
        Company, on_delete=models.CASCADE, verbose_name="société"
    )
    users = models.ManyToManyField(UserComp, verbose_name="utilisateurs", blank=True)
    group_name = models.CharField("nom", max_length=100)
    weight = models.IntegerField("poids", default=0)

    def __str__(self):
        return self.group_name

    class Meta:
        verbose_name = "Groupe d'utilisateurs"
        verbose_name_plural = "Groupes d'utilisateurs"

    @classmethod
    def create_group(cls, group_info):
        new_group = EventGroup(company=group_info["company"], group_name=group_info["group_name"], weight=group_info["weight"])
        new_group.save()
        return new_group

If it can help, here is the HTML code:

{% extends './base.html' %}

{% load static %}

{% block content %}

<div class="row">
    {% include "./adm_head.html" %}

    <div class="col-sm-9">
        <input type="hidden" id="menu_id" value="3" /> <!-- Hidden value to store the current selected menu -->
        <div class="row">
            <div id="admin-groups" class="col-sm-12 text-center">
                <h4 class="mt-5">Détails du groupe</h4>
            </div>
        </div>

        <div class="row">
            <div class="col-sm-12 mt-30">
                {% if grp_id %}
                <form action="{% url 'polls:adm_group_detail' company.comp_slug grp_id %}" method="post">
                {% else %}
                <form action="{% url 'polls:adm_group_detail' company.comp_slug %}" method="post">
                {% endif %}
                    {% csrf_token %}
                    <div class="row">
                        <div class="control-group {%if group_form.group_name.errors %}error{%endif%}"></div>
                        <div class="control-group {%if group_form.weight.errors %}error{%endif%}"></div>
                        {{ group_form.group_name}} {{ group_form.weight }}
                        <a type="button" id="disp_detail" class="collapse-group btn btn-sm" href="">
                            <span id="btn_grp" class="fas fa-chevron-up" data-toggle="tooltip" title="Masquer/Afficher détails"></span>
                        </a>
                    </div>
                    <div class="row mt-30 grp-content" id="grp_content">
                        <div class="col-md-5 d-flex justify-content-center">
                            <p>Utilisateurs</p>
                            {{ group_form.all_users}}
                        </div>
                        <div class="col-md-2 d-flex flex-column text-center justify-content-around">
                            <a type="button" id="add_all" class="update-user btn btn-sm" href="">
                                <span class="fa fa-fast-forward" style="color: #339af0;" data-toggle="tooltip" title="Ajouter tout"></span>
                            </a>
                            <a type="button" id="add_selected" class="update-user btn btn-sm" href="">
                                <span class="fa fa-step-forward" style="color: #339af0;" data-toggle="tooltip" title="Ajouter sélection"></span>
                            </a>
                            <a type="button" id="remove_selected" class="update-user btn btn-sm" href="">
                                <span class="fa fa-step-backward" style="color: #339af0;" data-toggle="tooltip" title="Retirer sélection"></span>
                            </a>
                            <a type="button" id="remove_all" class="update-user btn btn-sm" href="">
                                <span class="fa fa-fast-backward" style="color: #339af0;" data-toggle="tooltip" title="Retirer tout"></span>
                            </a>
                        </div>
                        <div class="col-md-5 d-flex justify-content-center">
                            <p>Utilisateurs sélectionnés</p><br>
                            {{ group_form.users }}
                            <div class="control-group {%if group_form.users.errors %}error{%endif%}"></div>
                        </div>
                    </div>
                    <div class="row">
                        <div class="col-sm-12 mt-30 text-center">
                            <button id='upd_grp' class="btn btn-success" type="submit">{% if grp_id %}Mettre à jour{% else %}Créer{% endif %}</button>
                            &nbsp &nbsp &nbsp
                            <a class="btn btn-secondary back_btn" href="*">Annuler</a>
                        </div>
                    </div>
                    <div class="row">
                        <div hidden>
                            <!-- Liste des ID du groupe -->
                            {{ group_form.group_list }}
                        </div>
                    </div>
                </form>
            </div>
    </div>


</div>

{% endblock %}

Solution

  • I think I found the answer I was looking for and a way to make my tests.
    The ideas was to find a way to separate each forms' contains, and it's done thanks to the right application of attributes: isolate the main form as a key of context dict, then use the fields attribute to filter and finally apply the queryset attribute to be able to manage related data accordingly.
    Then, the question was : 'how to make the comparison with this specific format?'. I found the answer by filtering on this object, taking advantage that .filter() will retrieve an empty list if no value is found, whereas a .get() would have raised an error.

    I will not make this answer the right one until I'm finished or I receive some comments or other ideas for better solution, but the following code for unit tests works very fine for me:

    class TestAdmGroups(TestCase):
        def setUp(self):
            self.company = create_dummy_company("Société de test")
    
            self.group1 = EventGroup.create_group({"company": self.company, "group_name": "Groupe 1", "weight": 40})
            self.group2 = EventGroup.create_group({"company": self.company, "group_name": "Groupe 2", "weight": 60})
            # self.group2 = EventGroup.objects.create(company=self.company, group_name="Groupe 2", weight=60)
    
            self.user_staff = create_dummy_user(self.company, "staff", group=self.group1, admin=True)
            self.usr11 = create_dummy_user(self.company, "user11", group=self.group1)
            self.usr12 = create_dummy_user(self.company, "user12", group=self.group1, admin=True)
            self.usr13 = create_dummy_user(self.company, "user13")
            self.usr14 = create_dummy_user(self.company, "user14")
            self.usr21 = create_dummy_user(self.company, "user21", group=self.group2)
            self.usr22 = create_dummy_user(self.company, "user22", group=self.group2)
    
        def test_adm_update_group(self):
            self.client.force_login(self.user_staff.user)
            url = reverse("polls:adm_group_detail", args=[self.company.comp_slug, self.group1.id])
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            group_users = response.context["group_form"].fields["users"].queryset
            test_user = group_users.filter(id=self.usr11.id)
            self.assertEqual(len(test_user), 1)
            test_user = group_users.filter(id=self.usr14.id)
            self.assertEqual(len(test_user), 0)