bennylope / django-organizations

:couple: Multi-user accounts for Django projects
http://django-organizations.readthedocs.org/
BSD 2-Clause "Simplified" License
1.31k stars 212 forks source link

Invite Multiple Users at Once? #181

Closed ScottHull closed 4 years ago

ScottHull commented 5 years ago

I'm a big fan of this app and have spent lots of time pouring over the source code. A feature I'd like to implement is to allow an organization administrator to invite multiple users at once (i.e. CSV file upload with email addresses to send invites to or a comma separated input field with email addresses). The views are pretty dense to parse and I was hoping someone could help me find where exactly I must interface with django-organizations in order to do this.

In the simplest example, I just convert the comma-separated field of user email addresses given by the form into a list and run a for-loop through the list to invite all of the users that were provided. I just need to figure out where the best place to insert this customization actually is! Any help would be appreciated!

EDIT: I suspect that the best place for this sort of functionality is in the invitation backend. I'm currently working with the default invitation in my project with very, very minor customization.

EDIT 2: Seems as though the for-loop I proposed is best executed in the OrganizationUserAddForm at organizations/forms.py. I'll update if I can get something working for my case!

EDIT 3: Working with forms integration into the front end is a bit painful. Seems as though Django's forms don't want to accept commas in the required fields, even though I plan to split them into a list in the backend.

ScottHull commented 5 years ago

It appears that I've got it working. Leaving my process here for review, in case anyone sees where improvement can be made. I made a few sloppy hacks to satisfy returns that the package expects, and I've commented in the code where those are.

forms.py (I've subclassed OrganizationUser and Organization models under CompanyUser and Company, respectively)

class CompanyUserAddForm(forms.ModelForm):
    """Form class for adding OrganizationUsers to an existing Organization"""
    email = forms.CharField()  # change from forms.EmailField so a comma-separated list will be accepted without throwing front-end errors

    def __init__(self, request, organization, *args, **kwargs):
        self.request = request
        self.organization = organization
        super(CompanyUserAddForm, self).__init__(*args, **kwargs)

    class Meta:
        model = models.CompanyUser
        exclude = ("user", "organization")

    def save(self, *args, **kwargs):

        provided_email_addresses = self.cleaned_data["email"].replace(" ", "").split(",")  # gather email addresses in a list
        added_users = []  # track users added so that one may be returned after loop, as expected by django-organizations

        for address in provided_email_addresses:  # loop through the collected addresses
            print(provided_email_addresses)
            print(address)

            if self.organization.users.filter(email=address).exists():  # got rid of clean_email(self) to avoid a premature email return, and put its functionality here
                raise forms.ValidationError(
                    _("There is already an organization " "member with the email address {}!".format(address))
                )

            """
            The save method should create a new OrganizationUser linking the User
            matching the provided email address. If not matching User is found it
            should kick off the registration process. It needs to create a User in
            order to link it to the Organization.
            """
            try:
                user = get_user_model().objects.get(
                    email__iexact=address
                )
            except get_user_model().MultipleObjectsReturned:
                raise forms.ValidationError(
                    _("The email address {} has been used multiple times.".format(address))
                )
            except get_user_model().DoesNotExist:
                user = invitation_backend().invite_by_email(
                    address,
                    **{
                        "domain": get_current_site(self.request),
                        "organization": self.organization,
                        "sender": self.request.user,
                    }
                )
            # Send a notification email to this user to inform them that they
            # have been added to a new organization.
            invitation_backend().send_notification(
                user,
                **{
                    "domain": get_current_site(self.request),
                    "organization": self.organization,
                    "sender": self.request.user,
                }
            )

            u = models.CompanyUser.objects.create(
                user=user,
                organization=self.organization,
                is_admin=self.cleaned_data["is_admin"],
            )

            added_users.append(u)

        return added_users[0]  # get_success_url in organizations view requires a returned user

views.py

class CompanyUserCreate(OrganizationUserCreate):
    form_class = forms.CompanyUserAddForm
ScottHull commented 5 years ago

Deleting the clean_email function caused the ValidationError to not be passed through the template. Here is my new, working form:

class CompanyUserAddForm(forms.ModelForm):
    """Form class for adding OrganizationUsers to an existing Organization"""
    email = forms.CharField()  # change from forms.EmailField so a comma-separated list will be accepted without throwing front-end errors

    def __init__(self, request, organization, *args, **kwargs):
        self.request = request
        self.organization = organization
        super(CompanyUserAddForm, self).__init__(*args, **kwargs)

    class Meta:
        model = models.CompanyUser
        exclude = ("user", "organization")

    def save(self, *args, **kwargs):

        email = self.cleaned_data["email"]  # gather email addresses in a list
        added_users = []  # track users added so that one may be returned after loop, as expected by django-organizations

        for address in email:  # loop through the collected addresses

            if self.organization.users.filter(email=address).exists():  # got rid of clean_email(self) to avoid a premature email return, and put its functionality here
                raise forms.ValidationError(
                    _("There is already an organization " "member with the email address {}!".format(address))
                )

            """
            The save method should create a new OrganizationUser linking the User
            matching the provided email address. If not matching User is found it
            should kick off the registration process. It needs to create a User in
            order to link it to the Organization.
            """
            try:
                user = get_user_model().objects.get(
                    email__iexact=address
                )
            except get_user_model().MultipleObjectsReturned:
                raise forms.ValidationError(
                    _("The email address {} has been used multiple times.".format(address))
                )
            except get_user_model().DoesNotExist:
                user = invitation_backend().invite_by_email(
                    address,
                    **{
                        "domain": get_current_site(self.request),
                        "organization": self.organization,
                        "sender": self.request.user,
                    }
                )
            # Send a notification email to this user to inform them that they
            # have been added to a new organization.
            invitation_backend().send_notification(
                user,
                **{
                    "domain": get_current_site(self.request),
                    "organization": self.organization,
                    "sender": self.request.user,
                }
            )

            u = models.CompanyUser.objects.create(
                user=user,
                organization=self.organization,
                is_admin=self.cleaned_data["is_admin"],
            )

            added_users.append(u)

        return added_users[0]  # get_success_url in organizations view requires a returned user

    def clean_email(self):
        email = self.cleaned_data['email'].replace(" ", "").split(",")
        for address in email:
            if self.organization.users.filter(email=address):
                raise forms.ValidationError(_("There is already an organization "
                                              "member with the email address {}!".format(address)))
            if validate_email(address):
                raise forms.ValidationError(_("The email address {} is not valid!".format(address)))
        return email
bennylope commented 4 years ago

Leaving this here for posterity, thanks @ScottHull.

If anyone finds this and wants to take a stab at modifying the default backends to handle this, that'd be welcome!