From 577c92248ac747244bf2dabe57fe1e39c6f8b92e Mon Sep 17 00:00:00 2001 From: Alejandro Franco Date: Tue, 21 Jun 2022 23:13:35 -0500 Subject: [PATCH] Update for custom CC-Django --- .../config/settings/base.py | 9 ++- .../users/adapters.py | 49 +++++++++++++ .../users/admin.py | 33 +++++++-- .../users/api/serializers.py | 4 +- .../users/api/views.py | 2 +- .../users/forms.py | 19 ++++- .../users/models.py | 73 ++++++++++++++++--- .../users/tests/test_drf_views.py | 6 +- .../users/urls.py | 2 +- .../users/views.py | 8 +- 10 files changed, 176 insertions(+), 29 deletions(-) diff --git a/{{cookiecutter.project_slug}}/config/settings/base.py b/{{cookiecutter.project_slug}}/config/settings/base.py index ad879fa72..be331a526 100644 --- a/{{cookiecutter.project_slug}}/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/config/settings/base.py @@ -302,13 +302,20 @@ CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" # django-allauth # ------------------------------------------------------------------------------ ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) +# https://django-allauth.readthedocs.io/en/latest/advanced.html#custom-user-models +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_USERNAME_REQUIRED = False # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = "username" +ACCOUNT_AUTHENTICATION_METHOD = "email" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_VERIFICATION = "mandatory" # https://django-allauth.readthedocs.io/en/latest/configuration.html +ACCOUNT_LOGOUT_ON_GET = True +# https://django-allauth.readthedocs.io/en/latest/configuration.html +SOCIALACCOUNT_LOGIN_ON_GET = True +# https://django-allauth.readthedocs.io/en/latest/configuration.html?highlight=SOCIALACCOUNT_LOGIN_ON_GET ACCOUNT_ADAPTER = "{{cookiecutter.project_slug}}.users.adapters.AccountAdapter" # https://django-allauth.readthedocs.io/en/latest/forms.html ACCOUNT_FORMS = {"signup": "{{cookiecutter.project_slug}}.users.forms.UserSignupForm"} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py index 0d206fae4..1c229442d 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py @@ -1,9 +1,14 @@ from typing import Any from allauth.account.adapter import DefaultAccountAdapter +from allauth.account.models import EmailAddress +from allauth.exceptions import ImmediateHttpResponse from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings +from django.contrib import messages from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse class AccountAdapter(DefaultAccountAdapter): @@ -14,3 +19,47 @@ class AccountAdapter(DefaultAccountAdapter): class SocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request: HttpRequest, sociallogin: Any): return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + def pre_social_login(self, request, sociallogin): + # social account already exists, so this is just a login + if sociallogin.is_existing: + return + + # some social logins don't have an email address + if not sociallogin.email_addresses: + return + + # find the first verified email that we get from this sociallogin + verified_email = None + for email in sociallogin.email_addresses: + if email.verified: + verified_email = email + break + + # no verified emails found, nothing more to do + if not verified_email: + return + + if request.user.is_authenticated and request.user.email != verified_email.email: + messages.error( + request, + """ + No es posible enlazar tu cuenta de {}, + ya que no coincide con tu correo en esta plataforma. + """.format( + list(request._socialapp_cache.keys())[0].capitalize() + ), + ) + raise ImmediateHttpResponse(redirect(reverse("socialaccount_connections"))) + + # check if given email address already exists as a verified email on + # an existing user's account + try: + existing_email = EmailAddress.objects.get( + email__iexact=verified_email.email, verified=True + ) + except EmailAddress.DoesNotExist: + return + + # if it does, connect this new social login to the existing user + sociallogin.connect(request, existing_email.user) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py index 6675f483a..b64ecbb74 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py @@ -10,12 +10,12 @@ User = get_user_model() @admin.register(User) class UserAdmin(auth_admin.UserAdmin): - form = UserAdminChangeForm add_form = UserAdminCreationForm + readonly_fields = ("uuid",) fieldsets = ( - (None, {"fields": ("username", "password")}), - (_("Personal info"), {"fields": ("name", "email")}), + (None, {"fields": ("email", "uuid", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name")}), ( _("Permissions"), { @@ -30,5 +30,28 @@ class UserAdmin(auth_admin.UserAdmin): ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) - list_display = ["username", "name", "is_superuser"] - search_fields = ["name"] + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "email", + "first_name", + "last_name", + "password1", + "password2", + ), + }, + ), + ) + list_display = [ + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "is_superuser", + ] + search_fields = ["email", "first_name", "last_name"] + ordering = ("email", "first_name", "last_name") diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py index b5ccabba1..fb9e6f7df 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py @@ -7,8 +7,8 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["username", "name", "url"] + fields = ["username", "first_name", "last_name", "url"] extra_kwargs = { - "url": {"view_name": "api:user-detail", "lookup_field": "username"} + "url": {"view_name": "api:user-detail", "lookup_field": "uuid"} } diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py index 98bb04e7b..bce2d5bb5 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py @@ -13,7 +13,7 @@ User = get_user_model() class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet): serializer_class = UserSerializer queryset = User.objects.all() - lookup_field = "username" + lookup_field = "uuid" def get_queryset(self, *args, **kwargs): assert isinstance(self.request.user.id, int) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py index 6e1dd9d32..770199059 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py @@ -10,6 +10,7 @@ User = get_user_model() class UserAdminChangeForm(admin_forms.UserChangeForm): class Meta(admin_forms.UserChangeForm.Meta): model = User + fields = ("email", "first_name", "last_name") class UserAdminCreationForm(admin_forms.UserCreationForm): @@ -20,9 +21,10 @@ class UserAdminCreationForm(admin_forms.UserCreationForm): class Meta(admin_forms.UserCreationForm.Meta): model = User + fields = ("email", "first_name", "last_name") error_messages = { - "username": {"unique": _("This username has already been taken.")} + "email": {"unique": _("This email has already been taken.")} } @@ -32,6 +34,21 @@ class UserSignupForm(SignupForm): Default fields will be added automatically. Check UserSocialSignupForm for accounts created from social. """ + first_name = forms.CharField( + max_length=150, + label=_("first name").capitalize(), + widget=forms.TextInput(attrs={"placeholder": _("first name").capitalize()}), + ) + last_name = forms.CharField( + max_length=150, + label=_("last name").capitalize(), + widget=forms.TextInput(attrs={"placeholder": _("last name").capitalize()}), + ) + + def custom_signup(self, request, user): + user.first_name = self.cleaned_data["first_name"] + user.last_name = self.cleaned_data["last_name"] + return super().custom_signup(request, user) class UserSocialSignupForm(SocialSignupForm): diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py index 1f6f61bc0..00aa50e44 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py @@ -4,17 +4,59 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -class User(AbstractUser): - """ - Default custom user model for {{cookiecutter.project_name}}. - If adding fields that need to be filled at user signup, - check forms.SignupForm and forms.SocialSignupForms accordingly. - """ +class UserManager(BaseUserManager): + """Define a model manager for User model with no username field.""" - #: First and last name do not cover name patterns around the globe - name = CharField(_("Name of User"), blank=True, max_length=255) - first_name = None # type: ignore - last_name = None # type: ignore + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + """Create and save a regular User with the given email and password.""" + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """Create and save a SuperUser with the given email and password.""" + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + """Default custom user model for {{cookiecutter.project_name}}.""" + + email = models.EmailField(_("email address"), unique=True) + username = None + uuid = models.UUIDField( + unique=True, db_index=True, default=uuid_lib.uuid4, editable=False + ) + first_name = models.CharField(_("first name"), max_length=150) + last_name = models.CharField(_("last name"), max_length=150) + + USERNAME_FIELD = "email" + + REQUIRED_FIELDS = [ + "first_name", + "last_name", + ] + + objects = UserManager() def get_absolute_url(self): """Get url for user's detail view. @@ -23,4 +65,13 @@ class User(AbstractUser): str: URL for user detail. """ - return reverse("users:detail", kwargs={"username": self.username}) + return reverse("users:detail", kwargs={"uuid": self.uuid}) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + def save(self, *args, **kwargs): + self.first_name = self.first_name.strip() + self.last_name = self.last_name.strip() + self.email = self.email.lower() + super().save(*args, **kwargs) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_drf_views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_drf_views.py index 924573089..906392944 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_drf_views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_drf_views.py @@ -27,7 +27,7 @@ class TestUserViewSet: response = view.me(request) assert response.data == { - "username": user.username, - "name": user.name, - "url": f"http://testserver/api/users/{user.username}/", + "email": user.uuid, + "name": user.first_name, + "url": f"http://testserver/api/users/{user.uuid}/", } diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py index 8c8c7e2ea..d9c638e13 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py @@ -10,5 +10,5 @@ app_name = "users" urlpatterns = [ path("~redirect/", view=user_redirect_view, name="redirect"), path("~update/", view=user_update_view, name="update"), - path("/", view=user_detail_view, name="detail"), + path("/", view=user_detail_view, name="detail"), ] diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py index baa04a0d4..ab0d245bb 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py @@ -11,8 +11,8 @@ User = get_user_model() class UserDetailView(LoginRequiredMixin, DetailView): model = User - slug_field = "username" - slug_url_kwarg = "username" + slug_field = "uuid" + slug_url_kwarg = "uuid" user_detail_view = UserDetailView.as_view() @@ -21,7 +21,7 @@ user_detail_view = UserDetailView.as_view() class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = User - fields = ["name"] + fields = ["first_name", "last_name"] success_message = _("Information successfully updated") def get_success_url(self): @@ -42,7 +42,7 @@ class UserRedirectView(LoginRequiredMixin, RedirectView): permanent = False def get_redirect_url(self): - return reverse("users:detail", kwargs={"username": self.request.user.username}) + return reverse("users:detail", kwargs={"uuid": self.request.user.uuid}) user_redirect_view = UserRedirectView.as_view()