Corvia / django-tenant-users

Adds global user authentication and tenant-specific permissions to django-tenants.
https://django-tenant-users.rtfd.io
MIT License
334 stars 65 forks source link

Using with AllAuth #188

Open daniel-butler opened 2 years ago

daniel-butler commented 2 years ago

This is a great package. I was wondering if there are examples of using this with allauth, or common pitfalls people run into.

I'll updating with information as I run into it. Currently I am working on the AdminCreation, AdminChange, Signup, and SocialSignup forms. The problem I have ran into is we don't use username only email - which is what I personally want.

Thank you!

Dresdn commented 2 years ago

I haven't used AllAuth with this, but off the top of my head, only thing I could see would be to ensure allauth is in the SHARED_APPS. Are you running into anything specifically (outside of the email comment)?

Regarding the email being the field, this comment somewhat relates to #152. I'm thinking of updating UserProfileManager to be more flexible by passing in a User object versus an email string. Then, update the instructions to use your own UserModel and use this package's Manager and define the tenants ManyToMany relationship to link it up.

Would something like that help?

daniel-butler commented 2 years ago

I was able to move past the initial error about the usename field. I updated the forms below from username to email.

This is the error I've been running into now:

<class 'users.admin.UserProfileAdmin'>: (admin.E019) The value of 'filter_horizontal[0]' refers to 'groups', which is not an attribute of 'users.TenantUser'.
<class 'users.admin.UserProfileAdmin'>: (admin.E019) The value of 'filter_horizontal[1]' refers to 'user_permissions', which is not an attribute of 'users.TenantUser'.
<class 'users.admin.UserProfileAdmin'>: (admin.E033) The value of 'ordering[0]' refers to 'username', which is not an attribute of 'users.TenantUser'.
<class 'users.admin.UserProfileAdmin'>: (admin.E108) The value of 'list_display[0]' refers to 'username', which is not a callable, an attribute of 'UserProfileAdmin', or an attribute or method on 'users.TenantUser'.
<class 'users.admin.UserProfileAdmin'>: (admin.E116) The value of 'list_filter[0]' refers to 'is_staff', which does not refer to a Field.
<class 'users.admin.UserProfileAdmin'>: (admin.E116) The value of 'list_filter[1]' refers to 'is_superuser', which does not refer to a Field.
<class 'users.admin.UserProfileAdmin'>: (admin.E116) The value of 'list_filter[3]' refers to 'groups', which does not refer to a Field.

I created a base app using cookiecutter-django and then added django-tenants and this project.

I've updated the ACCOUNT_AUTHENTICATION_METHOD setting to be "email" from "username"

This is the UserProfileAdmin it is complaining about

from django.contrib import admin
from django.contrib.auth import admin as auth_admin

from listing_service.users.forms import UserAdminChangeForm, UserAdminCreationForm

@admin.register(TenantUser)
class UserProfileAdmin(auth_admin.UserAdmin):

    form = UserAdminChangeForm
    add_form = UserAdminCreationForm

And the two attached forms

from django import forms
from django.contrib.auth import forms as admin_forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _

User = get_user_model()

class UserAdminChangeForm(admin_forms.UserChangeForm):
    class Meta(admin_forms.UserChangeForm.Meta):
        model = User
        field_classes = {"email": forms.EmailField}

class UserAdminCreationForm(admin_forms.UserCreationForm):
    """
    Form for User Creation in the Admin Area.
    To change user signup, see UserSignupForm and UserSocialSignupForm.
    """

    class Meta(admin_forms.UserCreationForm.Meta):
        model = User
        fields = ("email", )
        field_classes = {"email": forms.EmailField}

        error_messages = {
            "email": {"unique": _("This email has already been taken.")}
        }
daniel-butler commented 2 years ago

It seems like the error is related to the Inherited contrib.auth.admin.UserAdmin class

https://github.com/django/django/blob/main/django/contrib/auth/admin.py#L44

daniel-butler commented 2 years ago

Once I switched it from inheriting the UserAdmin it is running. I'll have to see what else is required to get the allauth flows working with this project.

daniel-butler commented 2 years ago

Also updating the user to use the tenant permission’s groups was needed.


class User(UserProfile):
    …

    @property
    def groups(self):
        return self.tenant_perms.groups
mihnen commented 1 year ago

FWIW I also had to add an additional property to get this to work for my app:

@property
def user_permissions(self):
    return self.tenant_perms.user_permissions
andrew-t34 commented 1 year ago

I get a similar problem. Is there a complete solution?

Dresdn commented 1 year ago

Hi @andrew-t34. Can you elaborate on what "similar problem" means as I don't want to assume it's specifically with the admin app.

Ultimately, I think there should be a documentation section for using with AllAuth written out.

max3007 commented 1 year ago

my solution to work with allauth ( login with username ) and show all the user fields in the admin site, hope it can help:

# user/models.py
from django.db import models
from tenant_users.tenants.models import UserProfile
from django.utils.translation import gettext_lazy as _
from django.utils import timezone

class TenantUser(UserProfile):

    EMAIL_FIELD = "email"
    USERNAME_FIELD = "username"
    REQUIRED_FIELDS = ["email"]

    name = models.CharField(_("Name of User"), blank=True, max_length=255)
    username = models.CharField(_("Username"), max_length=50, unique=True)
    date_joined = models.DateTimeField(_("date joined"), default=timezone.now)

    def clean(self):
        if not self.username:
            self.username = self.email
        super().clean()

    def save(self, *args, **kwargs):
        self.clean()
        super().save(*args, **kwargs)

    def __str__(self):
        return self.email

from django auth/admin.py and appropriate changes

#user/admin.py
from django.contrib import admin
from .models import TenantUser 
from tenant_users.permissions.models import UserTenantPermissions

from django.conf import settings
from django.contrib import admin, messages
from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.admin.utils import unquote
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import (
    AdminPasswordChangeForm,
    UserChangeForm,
    UserCreationForm,
)
from django.core.exceptions import PermissionDenied
from django.db import router, transaction
from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.decorators import method_decorator
from django.utils.html import escape
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters

csrf_protect_m = method_decorator(csrf_protect)
sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())

class UserTenantPermissionsInline(admin.StackedInline):
    model = UserTenantPermissions

@admin.register(TenantUser)
class UserAdmin(admin.ModelAdmin):
    add_form_template = "admin/auth/user/add_form.html"
    change_user_password_template = None
    inlines = [
        UserTenantPermissionsInline,
    ]
    fieldsets = (
        (None, {"fields": ("username", "password")}),
        (_("Personal info"), {"fields": ( "name", "email",)}),
        (_("Tenants"), {"fields": ( "tenants",)}),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_active",
                    "is_verified",
                ),
            },
        ),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "password1", "password2"),
            },
        ),
    )
    form = UserChangeForm
    add_form = UserCreationForm
    change_password_form = AdminPasswordChangeForm
    list_display = ( "email", "username",  )
    list_filter = ( "tenants",)
    search_fields = ("email","username",)
    ordering = ("email",)
    filter_horizontal = (
        "tenants",
    )

    def get_fieldsets(self, request, obj=None):
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)

    def get_form(self, request, obj=None, **kwargs):
        """
        Use special form during user creation
        """
        defaults = {}
        if obj is None:
            defaults["form"] = self.add_form
        defaults.update(kwargs)
        return super().get_form(request, obj, **defaults)

    def get_urls(self):
        return [
            path(
                "<id>/password/",
                self.admin_site.admin_view(self.user_change_password),
                name="auth_user_password_change",
            ),
        ] + super().get_urls()

    def lookup_allowed(self, lookup, value):
        # Don't allow lookups involving passwords.
        return not lookup.startswith("password") and super().lookup_allowed(
            lookup, value
        )

    @sensitive_post_parameters_m
    @csrf_protect_m
    def add_view(self, request, form_url="", extra_context=None):
        with transaction.atomic(using=router.db_for_write(self.model)):
            return self._add_view(request, form_url, extra_context)

    def _add_view(self, request, form_url="", extra_context=None):
        # It's an error for a user to have add permission but NOT change
        # permission for users. If we allowed such users to add users, they
        # could create superusers, which would mean they would essentially have
        # the permission to change users. To avoid the problem entirely, we
        # disallow users from adding users if they don't have change
        # permission.
        if not self.has_change_permission(request):
            if self.has_add_permission(request) and settings.DEBUG:
                # Raise Http404 in debug mode so that the user gets a helpful
                # error message.
                raise Http404(
                    'Your user does not have the "Change user" permission. In '
                    "order to add users, Django requires that your user "
                    'account have both the "Add user" and "Change user" '
                    "permissions set."
                )
            raise PermissionDenied
        if extra_context is None:
            extra_context = {}
        username_field = self.model._meta.get_field(self.model.USERNAME_FIELD)
        defaults = {
            "auto_populated_fields": (),
            "username_help_text": username_field.help_text,
        }
        extra_context.update(defaults)
        return super().add_view(request, form_url, extra_context)

    @sensitive_post_parameters_m
    def user_change_password(self, request, id, form_url=""):
        user = self.get_object(request, unquote(id))
        if not self.has_change_permission(request, user):
            raise PermissionDenied
        if user is None:
            raise Http404(
                _("%(name)s object with primary key %(key)r does not exist.")
                % {
                    "name": self.model._meta.verbose_name,
                    "key": escape(id),
                }
            )
        if request.method == "POST":
            form = self.change_password_form(user, request.POST)
            if form.is_valid():
                form.save()
                change_message = self.construct_change_message(request, form, None)
                self.log_change(request, user, change_message)
                msg = gettext("Password changed successfully.")
                messages.success(request, msg)
                update_session_auth_hash(request, form.user)
                return HttpResponseRedirect(
                    reverse(
                        "%s:%s_%s_change"
                        % (
                            self.admin_site.name,
                            user._meta.app_label,
                            user._meta.model_name,
                        ),
                        args=(user.pk,),
                    )
                )
        else:
            form = self.change_password_form(user)

        fieldsets = [(None, {"fields": list(form.base_fields)})]
        adminForm = admin.helpers.AdminForm(form, fieldsets, {})

        context = {
            "title": _("Change password: %s") % escape(user.get_username()),
            "adminForm": adminForm,
            "form_url": form_url,
            "form": form,
            "is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET),
            "is_popup_var": IS_POPUP_VAR,
            "add": True,
            "change": False,
            "has_delete_permission": False,
            "has_change_permission": True,
            "has_absolute_url": False,
            "opts": self.model._meta,
            "original": user,
            "save_as": False,
            "show_save": True,
            **self.admin_site.each_context(request),
        }

        request.current_app = self.admin_site.name

        return TemplateResponse(
            request,
            self.change_user_password_template
            or "admin/auth/user/change_password.html",
            context,
        )

    def response_add(self, request, obj, post_url_continue=None):
        """
        Determine the HttpResponse for the add_view stage. It mostly defers to
        its superclass implementation but is customized because the User model
        has a slightly different workflow.
        """
        # We should allow further modification of the user just added i.e. the
        # 'Save' button should behave like the 'Save and continue editing'
        # button except in two scenarios:
        # * The user has pressed the 'Save and add another' button
        # * We are adding a user in a popup
        if "_addanother" not in request.POST and IS_POPUP_VAR not in request.POST:
            request.POST = request.POST.copy()
            request.POST["_continue"] = 1
        return super().response_add(request, obj, post_url_continue)

to get this working:

  1. create a django superuser (python manage.py createsuperuser) ** it won't give you superuser property but you need it for public tenant
  2. create a public tenant (python manage.py create_tenant - schema name=public - owner = 1)
  3. create a tenant superuser (python manage.py create_tenant_superuser - Tenant Schema =public ) -> your superuser for admin login

It worked for me.

ajitaro commented 1 month ago

i was facing error

django.core.management.base.SystemCheckError: SystemCheckError: System check identified some issues:

ERRORS:
<class 'account.admin.CustomUserAdmin'>: (admin.E019) The value of 'filter_horizontal[0]' refers to 'groups', which is not a field of 'account.CustomUser'.
<class 'account.admin.CustomUserAdmin'>: (admin.E019) The value of 'filter_horizontal[1]' refers to 'user_permissions', which is not a field of 'account.CustomUser'.
<class 'account.admin.CustomUserAdmin'>: (admin.E116) The value of 'list_filter[1]' refers to 'is_superuser', which does not refer to a Field.       
<class 'account.admin.CustomUserAdmin'>: (admin.E116) The value of 'list_filter[3]' refers to 'groups', which does not refer to a Field.

and add this to admin.py

class CustomUserAdmin(UserAdmin):
    ...
    filter_horizontal = ()
    list_filter = ('is_active', 'is_staff')

and it works