Select members of ManyToManyField using CheckBoxes in a table row

Greetings,

I’ve been struggling trying to figure out how to display a form that allows users to add/remove Users from a single, given Organization (which contains a ManyToManyField). The form should provide a checkbox and a list of all Users for a given Organization, so we can add/remove rows in the through table (i.e., MemberWithUser).

Right now, I have two problems even getting my form to display the way I’d like:

  1. I cannot figure out how to render a checkbox in the first column without the labels for the users.
  2. How would I display the columns of User data (e.g. first_name, last_name, email, etc.), so we can see information about the user we’re selecting to add/remove from the given group?

Eventually, I will need to figure out how to validate/save this information to the through model (i.e., OrganizationWithMember), but one step at a time.

I have very limited experience with Forms, so this is very perplexing to me. My project is still very early, so if there are better ways to go about this, I’m all ears. I just need it to work. :slight_smile: I have been working on this problem for a couple of days with limited success, so any assistance would be really appreciated!

Below is all of my code.

# models.py
uuid_field = partial(
    models.UUIDField,
    auto_created=True,
    default=uuid4,
    editable=False,
    primary_key=True,
    unique=True,
)


class Organization(models.Model):
    members_users = models.ManyToManyField(
        User,
        blank=True,
        related_name="organization_users",
        through="OrganizationWithMember",
        verbose_name="Internal Directory",
    )

    def __str__(self) -> str:
        return str(self.name)

    def get_absolute_url(self) -> str:
        """Return the url for a specific record via it's PK"""

        return reverse("organizations:detail", kwargs={"pk": self.pk})


class User(AbstractUser):
    nickname = models.CharField("Nickname or Preferred name", max_length=150, blank=True)

    def get_absolute_url(self):
        return reverse("users:detail", kwargs={"pk": self.pk})

    def __str__(self):
        if self.nickname:
            firstname = self.nickname
        else:
            firstname = self.first_name
        return f"{firstname} {self.last_name}".strip()

class OrganizationWithMember(models.Model):
    """Organizations and their associated members"""

    uuid = uuid_field(db_column="organization_with_user_uuid")
    organization = models.ForeignKey(
        Organization,
        on_delete=models.CASCADE,
        db_column="organization_uuid_fk",
    )
    user = models.ForeignKey(User, on_delete=models.PROTECT, db_column="user_uuid_fk")

    class Meta:
        verbose_name = "Organization and its associated Member"
        verbose_name_plural = "Organizations with their associated Members"

    def __str__(self) -> str:
        return f"{self.organization.__str__()}_{self.user.__str__()}"

    def get_absolute_url(self) -> str:
        """Return the url for a specific record via it's PK"""

        return reverse("organization_with_member", kwargs={"pk": self.pk})


# views.py
class OrganizationMembershipUpdateView(UpdateView):
    context_object_name = "org"
    model = Organization
    # fields = ["members_users"]
    # table_class = OrganizationMembershipTable
    form_class = OrganizationMembershipUpdateForm
    template_name = "organizations/members/update.html"


# forms.py
class OrganizationMembershipUpdateForm(ModelForm):
    members_users = ModelMultipleChoiceField(
        queryset=User.objects.all(), widget=CheckboxSelectMultiple
    )

    class Meta:
        model = Organization
        fields = ["members_users"]

# update.html
{% block content %}
    <div class="container mt-2">
        <form method="post">
            {% csrf_token %}
            <table>
                {% for field in form.members_users %}
                    <tr>
                        <td>{{ field }}</td>
                        <td>{{ field.instance.members_users.user.first_name }}</td>
                        <td>{{ field.instance.members_users.user.last_name }}</td>
                        <td>{{ field.instance.members_users.user.email }}</td>
                    </tr>
                {% endfor %}
            </table>
            <div class="mt-3 mb-4">
                <button type="submit" class="btn btn-primary">Save</button>
            </div>
        </form>
    </div>
{% endblock content %}

edit: I was able to make a small bit of progress tonight getting all the Users to render in the form at least.

For others who might see this in the future, below you’ll find my solution. I created a custom widget that is subclassed from CheckboxSelectMultiple. My widget renders the related model’s rows (i.e., User in this case) to an HTML table. My project was already using django-tables2, so the table was straightforward to generate. You can control the fields/columns that are displayed in the table from the related model (User) via the fields attribute in OrganizationMembershipTable.Meta. Happy trails!

# apologies if I've missed any imports, this is a 
# bit cobbled together from multiple files.

from typing import Any

from django.jd import models
from django.forms import ModelMultipleChoiceField
from django.http import HttpRequest

from django.views.generic import UpdateView
from django.utils.safestring import SafeText, mark_safe
from django.urls import reverse
from django.utils.html import format_html


from django_tables2.columns import CheckBoxColumn
from django_tables2.tables import Table

from users.models import User

# models
class Organization(Model):
    """A logical or physical group of people."""

    members_users = models.ManyToManyField(
        User,
        blank=True,
        related_name="organization_users",
        through="OrganizationWithMember",
        verbose_name="Internal Directory",
    )
    tags = models.ManyToManyField(
        OrganizationTag,
        related_name="organization_tags",
        through="OrganizationWithOrganizationTag",
        verbose_name="organization tags",
    )

    class Meta:
        db_table = "Organization"

    def __str__(self) -> str:
        return str(self.name)

    def get_absolute_url(self) -> str:
        """Return the url for a specific record via it's PK"""

        return reverse("organizations:detail", kwargs={"pk": self.pk})


class OrganizationWithMember(Model):
    """Organizations and their associated members"""
    name = models.CharField(max_length=255)
    organization = models.ForeignKey(
        Organization,
        on_delete=models.CASCADE,
        db_column="organization_uuid_fk",
    )
    user = models.ForeignKey(User, on_delete=models.PROTECT, db_column="user_uuid_fk")

    class Meta:
        db_table = "OrganizationWithMember"
        verbose_name = "Organization and its associated Member"
        verbose_name_plural = "Organizations with their associated Members"

    def __str__(self) -> str:
        return f"{self.organization.__str__()}_{self.user.__str__()}"

    def get_absolute_url(self) -> str:
        """Return the url for a specific record via it's PK"""

        return reverse("organization_with_member", kwargs={"pk": self.pk})


# table


class OrganizationMembershipTable(Table):
    """Table that represents potential members of an Organization"""

    members_users = CheckBoxColumn(
        accessor="pk",
        orderable=False,
        attrs={
            "data-sortable": "false",
            "td": {
                "class": "th-inner bs-checkbox",
            },
            "td__input": {
                "class": "btSelectItem",
            },
            "th": {
                "class": "th-inner bs-checkbox",
                "data-sortable": "false",
                # "data-checkbox": "true",
            },
            "th__input": {
                "id": "header-checkbox",
            },
        },
        checked="members_users",
    )

    class Meta:
        attrs = table_attrs("Organization Members")
        empty_text = "There aren't any potential members for this organization."
        default = ""
        model = User
        fields = ("members_users", "preferred_name", "first_name", "last_name", "email")
        orderable = False
        template_name = "django_tables2/bootstrap5.html"

    def render_members_users(self, value, record, bound_column):
        checked = ""
        if record in self.m2m_members:
            checked = " checked"

        html = """<input type="checkbox" name="{}" value="{}"{}>""".format(  # NoQA E501
            bound_column.name, value, checked
        )
        return format_html(str(html))

    def __init__(self, *args, m2m_members: models.QuerySet[Any], **kwargs):
        super().__init__(*args, **kwargs)
        self.m2m_members = m2m_members


# forms and widgets


class CheckboxTableWidget(CheckboxSelectMultiple):
    """Widget that renders a django-tables2 Table with the first column checkboxes

    Suitable for rendering options to select for members of a ManyToManyField.
    """

    def __init__(self, table, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.table = table

    def render(self, name, value, attrs=None, renderer=None) -> SafeText:
        """Output the checkboxes and table to SafeText"""
        table_html = self.table.as_html(HttpRequest())
        return mark_safe(table_html) 


class OrganizationMembershipUpdateView(UpdateView):
    context_object_name = "org"
    fields = ("members_users",)
    model = Organization
    choices_qs = User.objects.all()
    template_name = "organizations/members/update.html"

    def get_table(self):
        org = self.get_object()
        m2m_members = org.members_users.all()

        # *** you could replace this with your own bespoke table if you like
        table = OrganizationMembershipTable(self.choices_qs, m2m_members=m2m_members)
        return table

    def get_form(self, form_class=None):
        form = super().get_form(form_class)
        table = self.get_table()
        form.fields[self.fields[0]] = ModelMultipleChoiceField(
            label="",
            queryset=self.choices_qs,
            widget=CheckboxTableWidget(table),
            required=False,
        )
        return form

1 Like