Add username_type option (#3958)

* Add username_type option

* Update docs

Signed-off-by: Andrew-Chen-Wang <acwangpython@gmail.com>

* Fix errors and lint

Signed-off-by: Andrew-Chen-Wang <acwangpython@gmail.com>

* Try linting again

* Try linting again

* Handle signup

* Fix url reverse in template

* Fix migration file

* Remove extra line in factories

* E2E tests for email username

* Fix style in users api views and serializers

* Fix style in users api views and serializers

* Add conditions for fields in UserAdminCreationForm

* Consistent Jinja tag conditions

* Fix more empty lines issues

* Tweak options order

* Test username email

* Fix code style

* Fix kwargs to reverse user details view

* Remove comment in template

* Fix error messages dict in user form

* Fix line wrapping

* Move custom manager into a separate module and add tests

---------

Signed-off-by: Andrew-Chen-Wang <acwangpython@gmail.com>
Co-authored-by: Bruno Alla <alla.brunoo@gmail.com>
Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com>
This commit is contained in:
Andrew Chen Wang 2023-04-15 06:53:15 -04:00 committed by GitHub
parent 42c30dafcb
commit 1783257ed3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 296 additions and 18 deletions

View File

@ -38,7 +38,7 @@ jobs:
matrix:
script:
- name: Basic
args: ""
args: "ci_tool=Gitlab"
- name: Celery & DRF
args: "use_celery=y use_drf=y"
- name: Gulp
@ -73,7 +73,9 @@ jobs:
- name: Gulp
args: "frontend_pipeline=Gulp"
- name: Webpack
args: "frontend_pipeline=Webpack"
args: "frontend_pipeline=Webpack use_heroku=y"
- name: Email Username
args: "username_type=email ci_tool=Github"
name: "Bare metal ${{ matrix.script.name }}"
runs-on: ubuntu-latest

View File

@ -118,6 +118,10 @@ Answer the prompts with your own desired [options](http://cookiecutter-django.re
4 - Apache Software License 2.0
5 - Not open source
Choose from 1, 2, 3, 4, 5 [1]: 1
Select username_type:
1 - username
2 - email
Choose from 1, 2 [1]: 1
timezone [UTC]: America/Los_Angeles
windows [n]: n
use_pycharm [n]: y

View File

@ -13,6 +13,7 @@
"Apache Software License 2.0",
"Not open source"
],
"username_type": ["username", "email"],
"timezone": "UTC",
"windows": "n",
"use_pycharm": "n",

View File

@ -24,6 +24,13 @@ author_name:
email:
The email address you want to identify yourself in the project.
username_type:
The type of username you want to use in the project. This can be either
``username`` or ``email``. If you choose ``username``, the ``email`` field
will be included. If you choose ``email``, the ``username`` field will be
excluded. It is best practice to always include an email field, so there is
no option for having just the ``username`` field.
domain_name:
The domain name you plan to use for your project once it goes live.
Note that it can be safely changed later on whenever you need to.

View File

@ -45,6 +45,24 @@ def remove_gplv3_files():
os.remove(file_name)
def remove_custom_user_manager_files():
os.remove(
os.path.join(
"{{cookiecutter.project_slug}}",
"users",
"managers.py",
)
)
os.remove(
os.path.join(
"{{cookiecutter.project_slug}}",
"users",
"tests",
"test_managers.py",
)
)
def remove_pycharm_files():
idea_dir_path = ".idea"
if os.path.exists(idea_dir_path):
@ -441,6 +459,9 @@ def main():
if "{{ cookiecutter.open_source_license}}" != "GPLv3":
remove_gplv3_files()
if "{{ cookiecutter.username_type }}" == "username":
remove_custom_user_manager_files()
if "{{ cookiecutter.use_pycharm }}".lower() == "n":
remove_pycharm_files()

View File

@ -36,6 +36,8 @@ def context():
SUPPORTED_COMBINATIONS = [
{"username_type": "username"},
{"username_type": "email"},
{"open_source_license": "MIT"},
{"open_source_license": "BSD"},
{"open_source_license": "GPLv3"},

View File

@ -312,9 +312,15 @@ CELERY_TASK_SEND_SENT_EVENT = True
# ------------------------------------------------------------------------------
ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True)
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_AUTHENTICATION_METHOD = "username"
ACCOUNT_AUTHENTICATION_METHOD = "{{cookiecutter.username_type}}"
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_EMAIL_REQUIRED = True
{%- if cookiecutter.username_type == "email" %}
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_USERNAME_REQUIRED = False
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
{%- endif %}
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
# https://django-allauth.readthedocs.io/en/latest/configuration.html

View File

@ -92,8 +92,7 @@
</li>
{% if request.user.is_authenticated %}
<li class="nav-item">
{# URL provided by django-allauth/account/urls.py #}
<a class="nav-link" href="{% url 'users:detail' request.user.username %}">{% translate "My Profile" %}</a>
<a class="nav-link" href="{% endraw %}{% if cookiecutter.username_type == "email" %}{% raw %}{% url 'users:detail' request.user.pk %}{% endraw %}{% else %}{% raw %}{% url 'users:detail' request.user.username %}{% endraw %}{% endif %}{% raw %}">{% translate "My Profile" %}</a>
</li>
<li class="nav-item">
{# URL provided by django-allauth/account/urls.py #}

View File

@ -1,7 +1,7 @@
{% raw %}{% extends "base.html" %}
{% load static %}
{% block title %}User: {{ object.username }}{% endblock %}
{% block title %}User: {% endraw %}{% if cookiecutter.username_type == "email" %}{% raw %}{{ object.name }}{% endraw %}{% else %}{% raw %}{{ object.username }}{% endraw %}{% endif %}{% raw %}{% endblock %}
{% block content %}
<div class="container">
@ -9,7 +9,7 @@
<div class="row">
<div class="col-sm-12">
<h2>{{ object.username }}</h2>
<h2>{% endraw %}{% if cookiecutter.username_type == "email" %}{% raw %}{{ object.name }}{% endraw %}{% else %}{% raw %}{{ object.username }}{% endraw %}{% endif %}{% raw %}</h2>
{% if object.name %}
<p>{{ object.name }}</p>
{% endif %}

View File

@ -1,10 +1,10 @@
{% raw %}{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}{{ user.username }}{% endblock %}
{% block title %}{% endraw %}{% if cookiecutter.username_type == "email" %}{% raw %}{{ user.name }}{% endraw %}{% else %}{% raw %}{{ user.username }}{% endraw %}{% endif %}{% raw %}{% endblock %}
{% block content %}
<h1>{{ user.username }}</h1>
<h1>{% endraw %}{% if cookiecutter.username_type == "email" %}{% raw %}{{ user.name }}{% endraw %}{% else %}{% raw %}{{ user.username }}{% endraw %}{% endif %}{% raw %}</h1>
<form class="form-horizontal" method="post" action="{% url 'users:update' %}">
{% csrf_token %}
{{ form|crispy }}

View File

@ -13,8 +13,13 @@ class UserAdmin(auth_admin.UserAdmin):
form = UserAdminChangeForm
add_form = UserAdminCreationForm
fieldsets = (
{%- if cookiecutter.username_type == "email" %}
(None, {"fields": ("email", "password")}),
(_("Personal info"), {"fields": ("name",)}),
{%- else %}
(None, {"fields": ("username", "password")}),
(_("Personal info"), {"fields": ("name", "email")}),
{%- endif %}
(
_("Permissions"),
{
@ -29,5 +34,17 @@ class UserAdmin(auth_admin.UserAdmin):
),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
)
list_display = ["username", "name", "is_superuser"]
list_display = ["{{cookiecutter.username_type}}", "name", "is_superuser"]
search_fields = ["name"]
{%- if cookiecutter.username_type == "email" %}
ordering = ["id"]
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
{%- endif %}

View File

@ -7,8 +7,16 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
{%- if cookiecutter.username_type == "email" %}
fields = ["name", "url"]
extra_kwargs = {
"url": {"view_name": "api:user-detail", "lookup_field": "pk"}
}
{%- else %}
fields = ["username", "name", "url"]
extra_kwargs = {
"url": {"view_name": "api:user-detail", "lookup_field": "username"}
}
{%- endif %}

View File

@ -13,7 +13,11 @@ User = get_user_model()
class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
serializer_class = UserSerializer
queryset = User.objects.all()
{%- if cookiecutter.username_type == "email" %}
lookup_field = "pk"
{%- else %}
lookup_field = "username"
{%- endif %}
def get_queryset(self, *args, **kwargs):
assert isinstance(self.request.user.id, int)

View File

@ -2,6 +2,9 @@ from allauth.account.forms import SignupForm
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
from django.contrib.auth import forms as admin_forms
from django.contrib.auth import get_user_model
{%- if cookiecutter.username_type == "email" %}
from django.forms import EmailField
{%- endif %}
from django.utils.translation import gettext_lazy as _
User = get_user_model()
@ -10,6 +13,9 @@ User = get_user_model()
class UserAdminChangeForm(admin_forms.UserChangeForm):
class Meta(admin_forms.UserChangeForm.Meta):
model = User
{%- if cookiecutter.username_type == "email" %}
field_classes = {"email": EmailField}
{%- endif %}
class UserAdminCreationForm(admin_forms.UserCreationForm):
@ -20,10 +26,17 @@ class UserAdminCreationForm(admin_forms.UserCreationForm):
class Meta(admin_forms.UserCreationForm.Meta):
model = User
{%- if cookiecutter.username_type == "email" %}
fields = ("email",)
field_classes = {"email": EmailField}
error_messages = {
"username": {"unique": _("This username has already been taken.")}
"email": {"unique": _("This email has already been taken.")},
}
{%- else %}
error_messages = {
"username": {"unique": _("This username has already been taken.")},
}
{%- endif %}
class UserSignupForm(SignupForm):

View File

@ -0,0 +1,34 @@
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import UserManager as DjangoUserManager
class UserManager(DjangoUserManager):
"""Custom manager for the User model."""
def _create_user(self, email: str, password: str | None, **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.password = make_password(password)
user.save(using=self._db)
return user
def create_user(self, email: str, password: str | None = None, **extra_fields):
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: str, password: str | None = None, **extra_fields):
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)

View File

@ -3,6 +3,8 @@ import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
import {{cookiecutter.project_slug}}.users.models
class Migration(migrations.Migration):
@ -40,6 +42,7 @@ class Migration(migrations.Migration):
verbose_name="superuser status",
),
),
{%- if cookiecutter.username_type == "username" -%}
(
"username",
models.CharField(
@ -61,6 +64,14 @@ class Migration(migrations.Migration):
blank=True, max_length=254, verbose_name="email address"
),
),
{%- else %}
(
"email",
models.EmailField(
unique=True, max_length=254, verbose_name="email address"
),
),
{%- endif %}
(
"is_staff",
models.BooleanField(
@ -118,7 +129,11 @@ class Migration(migrations.Migration):
"abstract": False,
},
managers=[
{%- if cookiecutter.username_type == "email" %}
("objects", {{cookiecutter.project_slug}}.users.models.UserManager()),
{%- else %}
("objects", django.contrib.auth.models.UserManager()),
{%- endif %}
],
),
]

View File

@ -1,7 +1,11 @@
from django.contrib.auth.models import AbstractUser
from django.db.models import CharField
from django.db.models import CharField{% if cookiecutter.username_type == "email" %}, EmailField{% endif %}
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
{%- if cookiecutter.username_type == "email" %}
from {{ cookiecutter.project_slug }}.users.managers import UserManager
{%- endif %}
class User(AbstractUser):
@ -11,16 +15,29 @@ class User(AbstractUser):
check forms.SignupForm and forms.SocialSignupForms accordingly.
"""
#: First and last name do not cover name patterns around the globe
# 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
{%- if cookiecutter.username_type == "email" %}
email = EmailField(_("email address"), unique=True)
username = None # type: ignore
def get_absolute_url(self):
"""Get url for user's detail view.
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
objects = UserManager()
{%- endif %}
def get_absolute_url(self) -> str:
"""Get URL for user's detail view.
Returns:
str: URL for user detail.
"""
{%- if cookiecutter.username_type == "email" %}
return reverse("users:detail", kwargs={"pk": self.id})
{%- else %}
return reverse("users:detail", kwargs={"username": self.username})
{%- endif %}

View File

@ -7,7 +7,9 @@ from factory.django import DjangoModelFactory
class UserFactory(DjangoModelFactory):
{%- if cookiecutter.username_type == "username" %}
username = Faker("user_name")
{%- endif %}
email = Faker("email")
name = Faker("name")
@ -29,4 +31,4 @@ class UserFactory(DjangoModelFactory):
class Meta:
model = get_user_model()
django_get_or_create = ["username"]
django_get_or_create = ["{{cookiecutter.username_type}}"]

View File

@ -22,16 +22,28 @@ class TestUserAdmin:
response = admin_client.post(
url,
data={
{%- if cookiecutter.username_type == "email" %}
"email": "new-admin@example.com",
{%- else %}
"username": "test",
{%- endif %}
"password1": "My_R@ndom-P@ssw0rd",
"password2": "My_R@ndom-P@ssw0rd",
},
)
assert response.status_code == 302
{%- if cookiecutter.username_type == "email" %}
assert User.objects.filter(email="new-admin@example.com").exists()
{%- else %}
assert User.objects.filter(username="test").exists()
{%- endif %}
def test_view_user(self, admin_client):
{%- if cookiecutter.username_type == "email" %}
user = User.objects.get(email="admin@example.com")
{%- else %}
user = User.objects.get(username="admin")
{%- endif %}
url = reverse("admin:users_user_change", kwargs={"object_id": user.pk})
response = admin_client.get(url)
assert response.status_code == 200

View File

@ -4,11 +4,19 @@ from {{ cookiecutter.project_slug }}.users.models import User
def test_user_detail(user: User):
{%- if cookiecutter.username_type == "email" %}
assert (
reverse("api:user-detail", kwargs={"pk": user.pk})
== f"/api/users/{user.pk}/"
)
assert resolve(f"/api/users/{user.pk}/").view_name == "api:user-detail"
{%- else %}
assert (
reverse("api:user-detail", kwargs={"username": user.username})
== f"/api/users/{user.username}/"
)
assert resolve(f"/api/users/{user.username}/").view_name == "api:user-detail"
{%- endif %}
def test_user_list():

View File

@ -29,7 +29,11 @@ class TestUserViewSet:
response = view.me(request) # type: ignore
assert response.data == {
{%- if cookiecutter.username_type == "email" %}
"url": f"http://testserver/api/users/{user.pk}/",
{%- else %}
"username": user.username,
"name": user.name,
"url": f"http://testserver/api/users/{user.username}/",
{%- endif %}
"name": user.name,
}

View File

@ -24,7 +24,11 @@ class TestUserAdminCreationForm:
# hence cannot be created.
form = UserAdminCreationForm(
{
{%- if cookiecutter.username_type == "email" %}
"email": user.email,
{%- else %}
"username": user.username,
{%- endif %}
"password1": user.password,
"password2": user.password,
}
@ -32,5 +36,10 @@ class TestUserAdminCreationForm:
assert not form.is_valid()
assert len(form.errors) == 1
{%- if cookiecutter.username_type == "email" %}
assert "email" in form.errors
assert form.errors["email"][0] == _("This email has already been taken.")
{%- else %}
assert "username" in form.errors
assert form.errors["username"][0] == _("This username has already been taken.")
{%- endif %}

View File

@ -0,0 +1,55 @@
from io import StringIO
import pytest
from django.core.management import call_command
from {{ cookiecutter.project_slug }}.users.models import User
@pytest.mark.django_db
class TestUserManager:
def test_create_user(self):
user = User.objects.create_user(
email="john@example.com",
password="something-r@nd0m!",
)
assert user.email == "john@example.com"
assert not user.is_staff
assert not user.is_superuser
assert user.check_password("something-r@nd0m!")
assert user.username is None
def test_create_superuser(self):
user = User.objects.create_superuser(
email="admin@example.com",
password="something-r@nd0m!",
)
assert user.email == "admin@example.com"
assert user.is_staff
assert user.is_superuser
assert user.username is None
def test_create_superuser_username_ignored(self):
user = User.objects.create_superuser(
email="test@example.com",
password="something-r@nd0m!",
)
assert user.username is None
@pytest.mark.django_db
def test_createsuperuser_command():
"""Ensure createsuperuser command works with our custom manager."""
out = StringIO()
command_result = call_command(
"createsuperuser",
"--email",
"henry@example.com",
interactive=False,
stdout=out,
)
assert command_result is None
assert out.getvalue() == "Superuser created successfully.\n"
user = User.objects.get(email="henry@example.com")
assert not user.has_usable_password()

View File

@ -2,4 +2,8 @@ from {{ cookiecutter.project_slug }}.users.models import User
def test_user_get_absolute_url(user: User):
{%- if cookiecutter.username_type == "email" %}
assert user.get_absolute_url() == f"/users/{user.pk}/"
{%- else %}
assert user.get_absolute_url() == f"/users/{user.username}/"
{%- endif %}

View File

@ -4,11 +4,16 @@ from {{ cookiecutter.project_slug }}.users.models import User
def test_detail(user: User):
{%- if cookiecutter.username_type == "email" %}
assert reverse("users:detail", kwargs={"pk": user.pk}) == f"/users/{user.pk}/"
assert resolve(f"/users/{user.pk}/").view_name == "users:detail"
{%- else %}
assert (
reverse("users:detail", kwargs={"username": user.username})
== f"/users/{user.username}/"
)
assert resolve(f"/users/{user.username}/").view_name == "users:detail"
{%- endif %}
def test_update():

View File

@ -39,7 +39,11 @@ class TestUserUpdateView:
view.request = request
{%- if cookiecutter.username_type == "email" %}
assert view.get_success_url() == f"/users/{user.pk}/"
{%- else %}
assert view.get_success_url() == f"/users/{user.username}/"
{%- endif %}
def test_get_object(self, user: User, rf: RequestFactory):
view = UserUpdateView()
@ -79,7 +83,11 @@ class TestUserRedirectView:
view.request = request
{%- if cookiecutter.username_type == "email" %}
assert view.get_redirect_url() == f"/users/{user.pk}/"
{%- else %}
assert view.get_redirect_url() == f"/users/{user.username}/"
{%- endif %}
class TestUserDetailView:
@ -87,7 +95,11 @@ class TestUserDetailView:
request = rf.get("/fake-url/")
request.user = UserFactory()
{%- if cookiecutter.username_type == "email" %}
response = user_detail_view(request, pk=user.pk)
{%- else %}
response = user_detail_view(request, username=user.username)
{%- endif %}
assert response.status_code == 200
@ -95,7 +107,11 @@ class TestUserDetailView:
request = rf.get("/fake-url/")
request.user = AnonymousUser()
{%- if cookiecutter.username_type == "email" %}
response = user_detail_view(request, pk=user.pk)
{%- else %}
response = user_detail_view(request, username=user.username)
{%- endif %}
login_url = reverse(settings.LOGIN_URL)
assert isinstance(response, HttpResponseRedirect)

View File

@ -10,5 +10,9 @@ app_name = "users"
urlpatterns = [
path("~redirect/", view=user_redirect_view, name="redirect"),
path("~update/", view=user_update_view, name="update"),
{%- if cookiecutter.username_type == "email" %}
path("<int:pk>/", view=user_detail_view, name="detail"),
{%- else %}
path("<str:username>/", view=user_detail_view, name="detail"),
{%- endif %}
]

View File

@ -10,8 +10,13 @@ User = get_user_model()
class UserDetailView(LoginRequiredMixin, DetailView):
model = User
{%- if cookiecutter.username_type == "email" %}
slug_field = "id"
slug_url_kwarg = "id"
{%- else %}
slug_field = "username"
slug_url_kwarg = "username"
{%- endif %}
user_detail_view = UserDetailView.as_view()
@ -39,7 +44,11 @@ class UserRedirectView(LoginRequiredMixin, RedirectView):
permanent = False
def get_redirect_url(self):
{%- if cookiecutter.username_type == "email" %}
return reverse("users:detail", kwargs={"pk": self.request.user.pk})
{%- else %}
return reverse("users:detail", kwargs={"username": self.request.user.username})
{%- endif %}
user_redirect_view = UserRedirectView.as_view()