diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/pydwiki/settings.py b/pydwiki/settings.py new file mode 100644 index 0000000..f4c421b --- /dev/null +++ b/pydwiki/settings.py @@ -0,0 +1,197 @@ +""" +Django settings for pydwiki project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from datetime import timedelta +from pathlib import Path + +from django.utils.translation import gettext_lazy as _l + +# ** プロジェクト名 +PROJECT_NAME = "pydwiki" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-a2k-_8p))*1=7zcpc9i=cwbmri@vytzt3-#ri_9jg-b%fkr*3v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "django_otp", + "django_otp.plugins.otp_totp", + "accounts", + "accounts_auth", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pydwiki.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pydwiki.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "ja" +LANGUAGES = [ + ("ja", _l("Japanese")), + ("en", _l("English")), +] + +TIME_ZONE = "Asia/Tokyo" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.CustomUser" + +# For Rest Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATIOIN_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "UPDATE_LAST_LOGIN": True, +} + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": "log_manager.formatter.CustomFormatter", + "format": "{asctime} [{levelname}] {pathname}:{lineno} {depth} {message}", + "style": "{", + }, + "simple": { + "format": "{asctime} [{levelname}] {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "pydwiki": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/pydwiki/settings.py b/pydwiki/settings.py new file mode 100644 index 0000000..f4c421b --- /dev/null +++ b/pydwiki/settings.py @@ -0,0 +1,197 @@ +""" +Django settings for pydwiki project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from datetime import timedelta +from pathlib import Path + +from django.utils.translation import gettext_lazy as _l + +# ** プロジェクト名 +PROJECT_NAME = "pydwiki" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-a2k-_8p))*1=7zcpc9i=cwbmri@vytzt3-#ri_9jg-b%fkr*3v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "django_otp", + "django_otp.plugins.otp_totp", + "accounts", + "accounts_auth", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pydwiki.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pydwiki.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "ja" +LANGUAGES = [ + ("ja", _l("Japanese")), + ("en", _l("English")), +] + +TIME_ZONE = "Asia/Tokyo" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.CustomUser" + +# For Rest Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATIOIN_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "UPDATE_LAST_LOGIN": True, +} + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": "log_manager.formatter.CustomFormatter", + "format": "{asctime} [{levelname}] {pathname}:{lineno} {depth} {message}", + "style": "{", + }, + "simple": { + "format": "{asctime} [{levelname}] {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "pydwiki": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/pydwiki/urls.py b/pydwiki/urls.py new file mode 100644 index 0000000..a63746e --- /dev/null +++ b/pydwiki/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for pydwiki project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: + Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') + Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') + Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), + path("api/accounts/", include("accounts.urls")), + path("api/accounts/auth/", include("accounts_auth.urls")), +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/pydwiki/settings.py b/pydwiki/settings.py new file mode 100644 index 0000000..f4c421b --- /dev/null +++ b/pydwiki/settings.py @@ -0,0 +1,197 @@ +""" +Django settings for pydwiki project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from datetime import timedelta +from pathlib import Path + +from django.utils.translation import gettext_lazy as _l + +# ** プロジェクト名 +PROJECT_NAME = "pydwiki" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-a2k-_8p))*1=7zcpc9i=cwbmri@vytzt3-#ri_9jg-b%fkr*3v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "django_otp", + "django_otp.plugins.otp_totp", + "accounts", + "accounts_auth", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pydwiki.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pydwiki.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "ja" +LANGUAGES = [ + ("ja", _l("Japanese")), + ("en", _l("English")), +] + +TIME_ZONE = "Asia/Tokyo" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.CustomUser" + +# For Rest Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATIOIN_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "UPDATE_LAST_LOGIN": True, +} + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": "log_manager.formatter.CustomFormatter", + "format": "{asctime} [{levelname}] {pathname}:{lineno} {depth} {message}", + "style": "{", + }, + "simple": { + "format": "{asctime} [{levelname}] {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "pydwiki": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/pydwiki/urls.py b/pydwiki/urls.py new file mode 100644 index 0000000..a63746e --- /dev/null +++ b/pydwiki/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for pydwiki project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: + Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') + Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') + Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), + path("api/accounts/", include("accounts.urls")), + path("api/accounts/auth/", include("accounts_auth.urls")), +] diff --git a/pydwiki/wsgi.py b/pydwiki/wsgi.py new file mode 100644 index 0000000..3b71876 --- /dev/null +++ b/pydwiki/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for pydwiki project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_wsgi_application() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/pydwiki/settings.py b/pydwiki/settings.py new file mode 100644 index 0000000..f4c421b --- /dev/null +++ b/pydwiki/settings.py @@ -0,0 +1,197 @@ +""" +Django settings for pydwiki project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from datetime import timedelta +from pathlib import Path + +from django.utils.translation import gettext_lazy as _l + +# ** プロジェクト名 +PROJECT_NAME = "pydwiki" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-a2k-_8p))*1=7zcpc9i=cwbmri@vytzt3-#ri_9jg-b%fkr*3v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "django_otp", + "django_otp.plugins.otp_totp", + "accounts", + "accounts_auth", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pydwiki.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pydwiki.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "ja" +LANGUAGES = [ + ("ja", _l("Japanese")), + ("en", _l("English")), +] + +TIME_ZONE = "Asia/Tokyo" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.CustomUser" + +# For Rest Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATIOIN_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "UPDATE_LAST_LOGIN": True, +} + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": "log_manager.formatter.CustomFormatter", + "format": "{asctime} [{levelname}] {pathname}:{lineno} {depth} {message}", + "style": "{", + }, + "simple": { + "format": "{asctime} [{levelname}] {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "pydwiki": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/pydwiki/urls.py b/pydwiki/urls.py new file mode 100644 index 0000000..a63746e --- /dev/null +++ b/pydwiki/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for pydwiki project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: + Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') + Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') + Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), + path("api/accounts/", include("accounts.urls")), + path("api/accounts/auth/", include("accounts_auth.urls")), +] diff --git a/pydwiki/wsgi.py b/pydwiki/wsgi.py new file mode 100644 index 0000000..3b71876 --- /dev/null +++ b/pydwiki/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for pydwiki project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2abba4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +alabaster==1.0.0 +asgiref==3.8.1 +babel==2.17.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +Django==5.1.5 +djangorestframework==3.15.2 +docutils==0.21.2 +idna==3.10 +imagesize==1.4.1 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +packaging==24.2 +Pygments==2.19.1 +requests==2.32.3 +snowballstemmer==2.2.0 +Sphinx==8.1.3 +sphinx-autodoc-typehints==3.0.1 +sphinx-rtd-theme==3.0.2 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +sqlparse==0.5.3 +urllib3==2.3.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf84dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +db.sqlite3 +docs/_build/ +docs/build/ +.python-version +.env +.venv + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/__init__.py diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..e2a35fc --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,134 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice + +from log_manager.trace_log import trace_log + +from .models import CustomUser, EmailAddress + + +class EmailAddressInlineFormSet(BaseInlineFormSet): + """メールアドレス用フォームセット。""" + + @trace_log + def clean(self): + """メールアドレスの primary 指定が複数ある場合、ValidationErro を投げます。""" + super().clean() + primary_count = sum(1 for form in self.forms if form.cleaned_data.get("is_primary", False)) + if primary_count > 1: + raise ValidationError(_("Only one primary email address can be set. ")) + + +class EmailAddressInline(admin.TabularInline): + """メールアドレスインライン用""" + + model = EmailAddress + formset = EmailAddressInlineFormSet + extra = 1 + + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + """カスタムユーザー管理。""" + + model = CustomUser + """ 対象モデル。 """ + + inlines = [ + EmailAddressInline, + ] + """ インライン表示。 """ + + list_display = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ リスト表示のフィールド。 """ + + search_fields = ( + "login_id", + "username", + "get_primary_email", + "is_mfa_enabled", + "is_staff", + "is_superuser", + ) + """ 検索フィールド。 """ + + ordering = ("login_id",) + """ 表示順。 """ + + fieldsets = ( + ( + None, + { + "fields": ( + "login_id", + "username", + "password", + "is_mfa_enabled", + ) + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_staff", + "is_active", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ( + _("Password"), + { + "fields": ( + "password_changed", + "password_changed_date", + ) + }, + ), + ) + """ ユーザー編集時のフィールドセット。 """ + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("login_id", "username", "_email", "password1", "password2"), + }, + ), + ) + """ ユーザー追加時のフィールドセット。 """ + + @trace_log + def get_primary_email(self, obj): + """プライマリのメールアドレスを返します。 + + Return: + プライマリのメールアドレス。 + """ + primary_email = obj.get_primary_email() + return primary_email if primary_email else _("None") + + get_primary_email.short_description = _("Email") + """ プライマリメールアドレスの詳細。 """ + + @trace_log + def save_model(self, request, obj, form, change): + """`email` が渡された場合、EmailAddress に保存します。""" + super().save_model(request, obj, form, change) + email = form.cleaned_data.get("_email") + if email and not EmailAddress.objects.filter(user=obj, email=email).exists(): + EmailAddress.objects.create(user=obj, email=email, is_primary=True) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..c821283 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = _("accounts") diff --git a/accounts/locale/en/LC_MESSAGES/django.mo b/accounts/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1ea489 --- /dev/null +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "" + +#: accounts/admin.py:120 +msgid "None" +msgstr "" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..5d27056 --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts/locale/ja/LC_MESSAGES/django.po b/accounts/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..c97fddc --- /dev/null +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,122 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: accounts/admin.py:21 +msgid "Only one primary email address can be set. " +msgstr "メインのメールアドレスは、1つだけ指定可能です。" + +#: accounts/admin.py:78 +msgid "Permissions" +msgstr "権限" + +#: accounts/admin.py:90 +msgid "Password" +msgstr "パスワード" + +#: accounts/admin.py:120 +msgid "None" +msgstr "なし" + +#: accounts/admin.py:122 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:18 +msgid "Email" +msgstr "メールアドレス" + +#: accounts/apps.py:8 +msgid "accounts" +msgstr "アカウント" + +#: accounts/models/custom_user.py:27 +msgid "Login ID" +msgstr "ログイン ID" + +#: accounts/models/custom_user.py:29 +msgid "" +"It must be 256 characters or less, and only alphanumeric characters and " +"@/./-/_ are allowed." +msgstr "256 文字以下で、英数字、@/./-/_ の記号のみ利用可能です。" + +#: accounts/models/custom_user.py:35 +msgid "Only alphanumeric characters and @/./-/_ are allowed." +msgstr "英数字、@/./-/_ のみ利用可能です。" + +#: accounts/models/custom_user.py:43 +msgid "User Name" +msgstr "ユーザー名" + +#: accounts/models/custom_user.py:56 +msgid "Active" +msgstr "有効" + +#: accounts/models/custom_user.py:62 +msgid "Staff" +msgstr "スタッフ権限" + +#: accounts/models/custom_user.py:68 +msgid "Super User" +msgstr "管理者権限" + +#: accounts/models/custom_user.py:74 +msgid "Joined" +msgstr "アカウント作成日時" + +#: accounts/models/custom_user.py:80 +msgid "Password changed" +msgstr "パスワード変更済" + +#: accounts/models/custom_user.py:87 +msgid "Password changed date" +msgstr "パスワード変更日時" + +#: accounts/models/custom_user.py:93 +msgid "MFA" +msgstr "MFA 有効" + +#: accounts/models/custom_user_manager.py:24 +msgid "Login ID is required" +msgstr "ログイン ID は、必須です。" + +#: accounts/models/custom_user_manager.py:26 +msgid "email is required" +msgstr "メールアドレス は、必須です。" + +#: accounts/models/custom_user_manager.py:58 +msgid "Superuser must have is_staff=True." +msgstr "管理者は、スタッフ権限が必須です。" + +#: accounts/models/custom_user_manager.py:60 +msgid "Superuser must have is_superuser=True." +msgstr "管理者は、管理者権限が必須です。" + +#: accounts/models/email_address.py:14 +msgid "User" +msgstr "ユーザー" + +#: accounts/models/email_address.py:21 +msgid "Primary" +msgstr "メイン" + +#: accounts/models/email_address.py:24 +msgid "Created at" +msgstr "作成日時" + +#: accounts/models/email_address.py:44 +msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +msgstr "{self.email} ({'メイン' if self.is_primary else '予備'})" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..ef2338a --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-07 07:07 + +import accounts.models.custom_user_manager +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('login_id', models.CharField(help_text='It must be 256 characters or less, and only alphanumeric characters and @/./-/_ are allowed.', max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_login_id', message='Only alphanumeric characters and @/./-/_ are allowed.', regex='^[a-zA-Z0-9@_.-]+$')], verbose_name='Login ID')), + ('username', models.CharField(max_length=256, verbose_name='User Name')), + ('_email', models.EmailField(max_length=254, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('is_staff', models.BooleanField(default=True, verbose_name='Staff')), + ('is_superuser', models.BooleanField(default=False, verbose_name='Super User')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Joined')), + ('password_changed', models.BooleanField(default=False, verbose_name='Password changed')), + ('password_changed_date', models.DateTimeField(blank=True, null=True, verbose_name='Password changed date')), + ('is_mfa_enabled', models.BooleanField(default=False, verbose_name='MFA')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'ユーザー', + 'verbose_name_plural': 'ユーザー', + }, + managers=[ + ('objects', accounts.models.custom_user_manager.CustomUserManager()), + ], + ), + migrations.CreateModel( + name='EmailAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()], verbose_name='Email')), + ('is_primary', models.BooleanField(default=False, verbose_name='Primary')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('user',), name='unique_primary_email')], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts/migrations/__init__.py diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py new file mode 100644 index 0000000..de31106 --- /dev/null +++ b/accounts/models/__init__.py @@ -0,0 +1,10 @@ +from .custom_user_manager import CustomUserManager +from .custom_user import CustomUser +from .email_address import EmailAddress + + +__all__ = [ + "CustomUserManager", + "CustomUser", + "EmailAddress", +] diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py new file mode 100644 index 0000000..6341780 --- /dev/null +++ b/accounts/models/custom_user.py @@ -0,0 +1,143 @@ +import logging + +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .custom_user_manager import CustomUserManager + +logger = logging.getLogger(__name__) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """カスタムユーザー。""" + + objects = CustomUserManager() + """ カスタムユーザー管理。 """ + + login_id = models.CharField( + unique=True, + blank=False, + null=False, + max_length=256, + verbose_name=_("Login ID"), + help_text=_( + "It must be 256 characters or less, and " + "only alphanumeric characters and @/./-/_ are allowed." + ), + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9@_.-]+$", + message=_("Only alphanumeric characters and @/./-/_ are allowed."), + code="invalid_login_id", + ), + ], + ) + """ ログインID。 """ + + username = models.CharField( + unique=False, blank=False, null=False, max_length=256, verbose_name=_("User Name") + ) + """ ユーザー名。 """ + + _email = models.EmailField(verbose_name=_("Email")) + """ メールアドレス。 + createsuperuser, createusr の設定用変数としてのみ使用。 + ``REQUIRED_FIELDS`` を参照。 + + """ + + is_active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + """ 有効/無効。 """ + + is_staff = models.BooleanField( + default=True, + verbose_name=_("Staff"), + ) + """ スタッフか否か。 """ + + is_superuser = models.BooleanField( + default=False, + verbose_name=_("Super User"), + ) + """ 管理者か否か。 """ + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name=_("Joined"), + ) + """ アカウント作成日時 """ + + password_changed = models.BooleanField( + default=False, + verbose_name=_("Password changed"), + ) + """ パスワード変更済みか否か """ + + password_changed_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Password changed date"), + ) + """ 最終パスワード変更日時 """ + + is_mfa_enabled = models.BooleanField( + default=False, + verbose_name=_("MFA"), + ) + """ MFA 有効/無効 """ + + USERNAME_FIELD = "login_id" + """ ログインID を認証用のID として使用する。""" + + REQUIRED_FIELDS = ["username", "_email"] + """ createsuperuser にてプロンプトが表示されるフィールドリスト。 """ + + @trace_log + def get_primary_email(self): + primary_email = self.emails.filter(is_primary=True).first() + return primary_email.email if primary_email else None + + """ プライマリメールアドレスを取得します。 + + Return: + プライマリのメールアドレス。 + """ + + def get_full_name(self): + """Full Name を取得します。 + + Return: + Full Name (=username) を返します。 + """ + return self.username + + def get_short_name(self): + """Short Name を取得します。 + + Return: + Short Name (=username) を返します。 + """ + return self.username + + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + return self.username + + class Meta: + verbose_name = "ユーザー" + """ 本モデルの名称。 """ + + verbose_name_plural = "ユーザー" + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/custom_user_manager.py b/accounts/models/custom_user_manager.py new file mode 100644 index 0000000..74f5ffc --- /dev/null +++ b/accounts/models/custom_user_manager.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import UserManager +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + + +class CustomUserManager(UserManager): + """カスタムユーザー管理""" + + @trace_log + def create_user(self, login_id, _email, password=None, **extra_fields): + """ユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したユーザー + """ + if not login_id: + raise ValueError(_("Login ID is required")) + if not _email: + raise ValueError(_("email is required")) + + # ユーザーを作成 + email = self.normalize_email(_email) + user = self.model(login_id=login_id, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + # メインのメールアドレスを登録する + from .email_address import EmailAddress + + EmailAddress.objects.create(user=user, email=email, is_primary=True) + + return user + + @trace_log + def create_superuser(self, login_id, _email, password=None, **extra_fields): + """スーパーユーザーを作成します。 + + Arguments: + login_ir: ログインID + _email: メールアドレス + password: パスワード + extra_field: その他フィールド + + Return: + 作成したスーパーユーザー + """ + 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(login_id, _email, password, **extra_fields) diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py new file mode 100644 index 0000000..57460ee --- /dev/null +++ b/accounts/models/email_address.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.core.validators import EmailValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmailAddress(models.Model): + """メールアドレス""" + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="emails", + verbose_name=_("User"), + ) + """ ユーザー。 """ + + email = models.EmailField(unique=True, validators=[EmailValidator()], verbose_name=_("Email")) + """ メールアドレス。 """ + + is_primary = models.BooleanField(default=False, verbose_name=_("Primary")) + """ プライマリー。 """ + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + """ 生成日時 """ + + def save(self, *args, **kwargs): + """保存の際呼び出されます。 + is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 + ※最後に is_primary=True を指定されたメールアドレスを唯一のプライマリとする。(ユーザー毎) + """ + if self.is_primary: + EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) + super().save(*args, **kwargs) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user"], condition=models.Q(is_primary=True), name="unique_primary_email" + ) + ] + + def __str__(self): + return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..84b1025 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import CustomUser, EmailAddress + + +class EmailAddressSerializer(serializers.ModelSerializer): + """メールアドレスシリアライザ。""" + + class Meta: + model = EmailAddress + """ 対象モデル """ + + fields = ( + "user", + "email", + "is_primary", + ) + """ 対象フィールド """ + + +class CustomUserSerializer(serializers.ModelSerializer): + """カスタムユーザーシリアライザ。""" + + emails = EmailAddressSerializer(many=True, read_only=True) + """ メールアドレス(複数) """ + + email_count = serializers.SerializerMethodField() + """ メールアドレス数 """ + + class Meta: + + model = CustomUser + """ 対象モデル。 """ + + fields = ( + "id", + "login_id", + "username", + "is_staff", + "is_active", + "is_superuser", + "password_changed", + "password_changed_date", + "emails", + "email_count", + ) + """ 対象フィールド。 """ + + # read_only_fiields = ("login_id", ) + def get_email_count(self, obj): + """メールアドレス数を取得します。 + + Arguments: + obj: 対象インスタンス + + Return: + メールアドレス数 + """ + return obj.emails.count() + + def create(self, validated_data): + """生成時に呼び出されます。 + + Arguments: + validated_data: 検証されたデータ + + Return: + 生成したユーザー + """ + emails_data = validated_data.pop("emails", []) + user = CustomUser.objects.create(**validated_data) + + for email_data in emails_data: + EmailAddress.objects.create(user=user, **email_data) + + return user + + def update(self, instance, validated_data): + """更新時に呼び出されます。 + + Arguments: + instance: インスタンス + validated_data: 検証されたデータ + + Return: + インスタンス + """ + emails_data = validated_data.pop("emails", []) + instance.login_id = validated_data.get("login_id", instance.login_id) + instance.username = validated_data.get("username", instance.username) + instance.is_staff = validated_data.get("is_staff", instance.is_staff) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) + instance.password_changed = validated_data.get( + "password_changed", instance.password_changed + ) + instance.password_changed_date = validated_data.get( + "password_changed_date", instance.password_changed + ) + instance.save() + + existing_emails = {email.id: email for email in instance.email.all()} + new_emails = [] + + for email_data in emails_data: + email_id = email_data.get("id", None) + if email_id and email_id in existing_emails: + email_instance = existing_emails.pop(email_id) + for attr, value in email_data.items(): + setattr(email_instance, attr, value) + email_instance.save() + else: + new_emails.append(EmailAddress(user=instance, **email_data)) + + for email in existing_emails.values(): + email.delete() + + EmailAddress.objects.bulk_create(new_emails) + return instance diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..eaf25a3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CustomUserViewSet, EmailAddressViewSet + +router = DefaultRouter() +router.register(r"users", CustomUserViewSet) +router.register(r"emailaddress", EmailAddressViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9404a9e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from .models import CustomUser, EmailAddress +from .serializers import CustomUserSerializer, EmailAddressSerializer + + +class CustomUserViewSet(viewsets.ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = CustomUserSerializer + + +class EmailAddressViewSet(viewsets.ModelViewSet): + queryset = EmailAddress.objects.all() + serializer_class = EmailAddressSerializer diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/accounts_auth/__init__.py diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py new file mode 100644 index 0000000..0a0e538 --- /dev/null +++ b/accounts_auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsAuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts_auth' diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.mo b/accounts_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..1c1ee4f --- /dev/null +++ b/accounts_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.mo b/accounts_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..4a0ec3b --- /dev/null +++ b/accounts_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: accounts_auth/serializers.py:45 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: accounts_auth/serializers.py:59 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/accounts_auth/models.py b/accounts_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py new file mode 100644 index 0000000..2c4a3ba --- /dev/null +++ b/accounts_auth/serializers.py @@ -0,0 +1,61 @@ +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from accounts.models.custom_user import CustomUser +from log_manager.trace_log import trace_log + +logger = getLogger(__name__) + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """MFA 認証に必要な、"otp" (One-Time Password) フィールドを追加したトークン取得用のシリアライザ。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + """TokenObtainPairSerializer に otp フィールドを追加する。""" + super().__init__(*args, **kwargs) + + # otp (One-Time Password) フィールドを追加 + self.fields["otp"] = OtpField(required=False) + + @trace_log + def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]: + """ユーザーの MFA 有効であり、OTP が指定されている場合、OTP の検証を実施する。 + + Arguments: + attrs: 属性 + + Return: + アクセストークン、リフレッシュトーク が含まれる辞書 + """ + data = super().validate(attrs) + + user = cast(CustomUser, self.user) + if user.is_mfa_enabled: + # MFA が有効な場合、OTP を検証する。 + otp = attrs.get("otp") + device = TOTPDevice.objects.get(user=user) + if not otp or not otp or not device or not device.verify_token(otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + + return data + + +class OtpField(serializers.CharField): + """6桁の数値を扱う、OTP 用フィールド。""" + + @trace_log + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("style", {}) + kwargs["style"]["input_type"] = "number" + kwargs["write_only"] = True + kwargs["validators"] = [ + RegexValidator(regex=r"^\d{6}$", message=_("OTP must be a 6-digit number.")), + ] + super().__init__(*args, **kwargs) diff --git a/accounts_auth/tests.py b/accounts_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/accounts_auth/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import CustomTokenObtainPairView, MfaAuthViewSet + +router = DefaultRouter() +router.register(r"", MfaAuthViewSet, basename="mfa") + +urlpatterns = [ + path("token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("mfa/", include(router.urls)), +] diff --git a/accounts_auth/views.py b/accounts_auth/views.py new file mode 100644 index 0000000..7a5d445 --- /dev/null +++ b/accounts_auth/views.py @@ -0,0 +1,75 @@ +import base64 +from io import BytesIO +from logging import getLogger + +import qrcode +from django.contrib.auth import get_user_model +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView + +from log_manager.trace_log import trace_log + +from .serializers import CustomTokenObtainPairSerializer + +logger = getLogger(__name__) + + +# Create your views here. + +User = get_user_model() + + +class CustomTokenObtainPairView(TokenObtainPairView): + """カスタムトークン取得用 View。 + ID、パスワードにより認証されたユーザーの Access Toekn, Refresh Token を返します。 + 対象ユーザーの MFA が有効な場合、パスワードに加え、otp (One-Time Password) を要求します。 + """ + + serializer_class = CustomTokenObtainPairSerializer + """ シリアライザ。 """ + + +class MfaAuthViewSet(viewsets.ViewSet): + permission_class = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + device, created = TOTPDevice.objects.get_or_create(user=user, name="defualt") + + # QR コード生成 + otp_url = device.config_url + qr = qrcode.make(otp_url) + buffer = BytesIO() + qr.save(buffer, format="PNG") + qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return Response({"qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の設定 QR コードを返します。""" + user = request.user + device = TOTPDevice.objects.filter(user=user).first() + + otp = request.data.get("otp") + if device and device.verify_token(otp): + user.is_mfa_enabled = True + user.save() + return Response({"message": "MFA enabled successfully"}) + return Response({"error": "Invalid OTP"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh new file mode 100755 index 0000000..f11bf58 --- /dev/null +++ b/docs/mkdocs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +IS_CLEAN=$1 + +if [ "${IS_CLEAN}" == "clean" ]; then + ls source/*.rst | grep -v 'index.rst' | xargs rm -f +fi +(cd .. && sphinx-apidoc -o docs/source . "**/migrations" "trans") +(cd .. && sphinx-build -v -b html -E docs/source/ docs/build) + diff --git a/docs/source/accounts.models.rst b/docs/source/accounts.models.rst new file mode 100644 index 0000000..c87b2d5 --- /dev/null +++ b/docs/source/accounts.models.rst @@ -0,0 +1,37 @@ +accounts.models package +======================= + +Submodules +---------- + +accounts.models.custom\_user module +----------------------------------- + +.. automodule:: accounts.models.custom_user + :members: + :undoc-members: + :show-inheritance: + +accounts.models.custom\_user\_manager module +-------------------------------------------- + +.. automodule:: accounts.models.custom_user_manager + :members: + :undoc-members: + :show-inheritance: + +accounts.models.email\_address module +------------------------------------- + +.. automodule:: accounts.models.email_address + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst new file mode 100644 index 0000000..f99115e --- /dev/null +++ b/docs/source/accounts.rst @@ -0,0 +1,69 @@ +accounts package +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + accounts.models + +Submodules +---------- + +accounts.admin module +--------------------- + +.. automodule:: accounts.admin + :members: + :undoc-members: + :show-inheritance: + +accounts.apps module +-------------------- + +.. automodule:: accounts.apps + :members: + :undoc-members: + :show-inheritance: + +accounts.serializers module +--------------------------- + +.. automodule:: accounts.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts.tests module +--------------------- + +.. automodule:: accounts.tests + :members: + :undoc-members: + :show-inheritance: + +accounts.urls module +-------------------- + +.. automodule:: accounts.urls + :members: + :undoc-members: + :show-inheritance: + +accounts.views module +--------------------- + +.. automodule:: accounts.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst new file mode 100644 index 0000000..4fe1708 --- /dev/null +++ b/docs/source/accounts_auth.rst @@ -0,0 +1,69 @@ +accounts\_auth package +====================== + +Submodules +---------- + +accounts\_auth.admin module +--------------------------- + +.. automodule:: accounts_auth.admin + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.apps module +-------------------------- + +.. automodule:: accounts_auth.apps + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.models module +---------------------------- + +.. automodule:: accounts_auth.models + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.serializers module +--------------------------------- + +.. automodule:: accounts_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.tests module +--------------------------- + +.. automodule:: accounts_auth.tests + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.urls module +-------------------------- + +.. automodule:: accounts_auth.urls + :members: + :undoc-members: + :show-inheritance: + +accounts\_auth.views module +--------------------------- + +.. automodule:: accounts_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: accounts_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5312677 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,42 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +# For Django +import django +os.environ["DJANGO_SETTINGS_MODULE"] = "pydwiki.settings" +django.setup() + +project = 'pydwiki' +copyright = '2025, Nomura Kei' +author = 'Nomura Kei' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [ + '**/migrations/*', +] + +language = 'ja' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# html_theme = 'alabaster' +html_theme = 'nature' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..600115c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,8 @@ +pydwiki documentation +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst new file mode 100644 index 0000000..88bf13b --- /dev/null +++ b/docs/source/log_manager.rst @@ -0,0 +1,69 @@ +log\_manager package +==================== + +Submodules +---------- + +log\_manager.admin module +------------------------- + +.. automodule:: log_manager.admin + :members: + :undoc-members: + :show-inheritance: + +log\_manager.apps module +------------------------ + +.. automodule:: log_manager.apps + :members: + :undoc-members: + :show-inheritance: + +log\_manager.formatter module +----------------------------- + +.. automodule:: log_manager.formatter + :members: + :undoc-members: + :show-inheritance: + +log\_manager.models module +-------------------------- + +.. automodule:: log_manager.models + :members: + :undoc-members: + :show-inheritance: + +log\_manager.tests module +------------------------- + +.. automodule:: log_manager.tests + :members: + :undoc-members: + :show-inheritance: + +log\_manager.trace\_log module +------------------------------ + +.. automodule:: log_manager.trace_log + :members: + :undoc-members: + :show-inheritance: + +log\_manager.views module +------------------------- + +.. automodule:: log_manager.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: log_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/manage.rst b/docs/source/manage.rst new file mode 100644 index 0000000..776b9e3 --- /dev/null +++ b/docs/source/manage.rst @@ -0,0 +1,7 @@ +manage module +============= + +.. automodule:: manage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..8ad6fe2 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,12 @@ +pydwiki +======= + +.. toctree:: + :maxdepth: 4 + + accounts + accounts_auth + log_manager + manage + pydwiki + trans diff --git a/docs/source/pydwiki.rst b/docs/source/pydwiki.rst new file mode 100644 index 0000000..af52043 --- /dev/null +++ b/docs/source/pydwiki.rst @@ -0,0 +1,45 @@ +pydwiki package +=============== + +Submodules +---------- + +pydwiki.asgi module +------------------- + +.. automodule:: pydwiki.asgi + :members: + :undoc-members: + :show-inheritance: + +pydwiki.settings module +----------------------- + +.. automodule:: pydwiki.settings + :members: + :undoc-members: + :show-inheritance: + +pydwiki.urls module +------------------- + +.. automodule:: pydwiki.urls + :members: + :undoc-members: + :show-inheritance: + +pydwiki.wsgi module +------------------- + +.. automodule:: pydwiki.wsgi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pydwiki + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/trans.rst b/docs/source/trans.rst new file mode 100644 index 0000000..92caef6 --- /dev/null +++ b/docs/source/trans.rst @@ -0,0 +1,7 @@ +trans module +============ + +.. automodule:: trans + :members: + :undoc-members: + :show-inheritance: diff --git a/log_manager/__init__.py b/log_manager/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log_manager/__init__.py diff --git a/log_manager/admin.py b/log_manager/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/log_manager/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/log_manager/apps.py b/log_manager/apps.py new file mode 100644 index 0000000..9ae8060 --- /dev/null +++ b/log_manager/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogManagerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'log_manager' diff --git a/log_manager/formatter.py b/log_manager/formatter.py new file mode 100644 index 0000000..37d5cb0 --- /dev/null +++ b/log_manager/formatter.py @@ -0,0 +1,32 @@ +import logging + + +class CustomFormatter(logging.Formatter): + """カスタムフォーマッター。 + trace_log を用いる際、pathname, lineno が、 + trace_log になることを防ぐためのフォーマッター。 + """ + + def format(self, record): + """フォーマットします。 + + Arguments: + record: レコード + + Return: + フォーマットされたメッセージ + """ + + record.depth = "" + + # ext_* が指定されていれば、置き換える。 + if "ext_depth" in record.__dict__: + record.depth = record.__dict__["ext_depth"] + if "ext_levelname" in record.__dict__: + record.levelname = record.__dict__["ext_levelname"] + if "ext_pathname" in record.__dict__: + record.pathname = record.__dict__["ext_pathname"] + if "ext_lineno" in record.__dict__: + record.lineno = record.__dict__["ext_lineno"] + + return super().format(record) diff --git a/log_manager/models.py b/log_manager/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/log_manager/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/log_manager/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py new file mode 100644 index 0000000..a0f5486 --- /dev/null +++ b/log_manager/trace_log.py @@ -0,0 +1,76 @@ +import functools +import inspect +import logging +import threading + +thread_local = threading.local() + + +def initialize_thread_local(): + """スレッドローカルの初期化をします。""" + if not hasattr(thread_local, "depth"): + thread_local.depth = 1 + + +def trace_log(func): + """トレースログを出力します。 + + Arguments: + func: 関数 + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + initialize_thread_local() + + logger = logging.getLogger(func.__module__) + f_pathname = inspect.getfile(func) + f_lineno = inspect.getsourcelines(func)[1] + + # 関数呼出し前ログ出力 + actual_args = args[1:] if args and hasattr(args[0], func.__name__) else args + ext_depth = ">" * thread_local.depth + logger.debug( + f"{func.__name__}: args={actual_args}, {kwargs=}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + + thread_local.depth += 1 + try: + # 関数実行 + result = func(*args, **kwargs) + + # 関数呼出し後ログ出力 + thread_local.depth -= 1 + ext_depth = "<" * thread_local.depth + logger.debug( + f"{func.__name__}: return={result}", + extra={ + "ext_depth": ext_depth, + "ext_levelname": "TRACE", + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + return result + except Exception as e: + + # エラー発生時ログ出力 + thread_local.depth -= 1 + ext_depth = "#" * thread_local.depth + logger.error( + f"{func.__name__}: {e=}", + extra={ + "ext_depth": ext_depth, + "ext_pathname": f_pathname, + "ext_lineno": f_lineno, + }, + ) + raise + + return wrapper diff --git a/log_manager/views.py b/log_manager/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/log_manager/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9522684 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pydwiki/__init__.py b/pydwiki/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydwiki/__init__.py diff --git a/pydwiki/asgi.py b/pydwiki/asgi.py new file mode 100644 index 0000000..a867e12 --- /dev/null +++ b/pydwiki/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for pydwiki project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_asgi_application() diff --git a/pydwiki/settings.py b/pydwiki/settings.py new file mode 100644 index 0000000..f4c421b --- /dev/null +++ b/pydwiki/settings.py @@ -0,0 +1,197 @@ +""" +Django settings for pydwiki project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from datetime import timedelta +from pathlib import Path + +from django.utils.translation import gettext_lazy as _l + +# ** プロジェクト名 +PROJECT_NAME = "pydwiki" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-a2k-_8p))*1=7zcpc9i=cwbmri@vytzt3-#ri_9jg-b%fkr*3v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "django_otp", + "django_otp.plugins.otp_totp", + "accounts", + "accounts_auth", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pydwiki.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pydwiki.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "ja" +LANGUAGES = [ + ("ja", _l("Japanese")), + ("en", _l("English")), +] + +TIME_ZONE = "Asia/Tokyo" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "accounts.CustomUser" + +# For Rest Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATIOIN_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "UPDATE_LAST_LOGIN": True, +} + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": "log_manager.formatter.CustomFormatter", + "format": "{asctime} [{levelname}] {pathname}:{lineno} {depth} {message}", + "style": "{", + }, + "simple": { + "format": "{asctime} [{levelname}] {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "pydwiki": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/pydwiki/urls.py b/pydwiki/urls.py new file mode 100644 index 0000000..a63746e --- /dev/null +++ b/pydwiki/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for pydwiki project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: + Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') + Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') + Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), + path("api/accounts/", include("accounts.urls")), + path("api/accounts/auth/", include("accounts_auth.urls")), +] diff --git a/pydwiki/wsgi.py b/pydwiki/wsgi.py new file mode 100644 index 0000000..3b71876 --- /dev/null +++ b/pydwiki/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for pydwiki project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydwiki.settings') + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2abba4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +alabaster==1.0.0 +asgiref==3.8.1 +babel==2.17.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +Django==5.1.5 +djangorestframework==3.15.2 +docutils==0.21.2 +idna==3.10 +imagesize==1.4.1 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +packaging==24.2 +Pygments==2.19.1 +requests==2.32.3 +snowballstemmer==2.2.0 +Sphinx==8.1.3 +sphinx-autodoc-typehints==3.0.1 +sphinx-rtd-theme==3.0.2 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +sqlparse==0.5.3 +urllib3==2.3.0 diff --git a/trans.py b/trans.py new file mode 100755 index 0000000..ca5f5d5 --- /dev/null +++ b/trans.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python + +import os +import sys + +from django.core.management import call_command +from django.apps import apps +from pydwiki.settings import LANGUAGES, INSTALLED_APPS + +# 対応言語リスト取得 +langs = [lang[0] for lang in LANGUAGES] + + +def get_app_names(): + """ アプリ名一覧を取得します。 + 配下に、アプリ名と同じディレクトリがあるものだけを抽出して返します。 + + Return: + アプリ名一覧 + """ + filterd_app_names = list(filter(lambda name: os.path.isdir(name), INSTALLED_APPS)) + return filterd_app_names + +def makemessages(): + """ python manage.py makemessages -l '言語' と同様の処理を実施し、 + locale/{言語}/LC_MESSAGES 配下に、po ファイルを生成します。 + """ + + # ディレクトリ作成 + app_names = get_app_names() + for app_name in app_names: + for lang in langs: + locale_dir= f"{app_name}/locale/{lang}/LC_MESSAGES" + os.makedirs(locale_dir, exist_ok=True) + + # po ファイル生成 + # python manage.py makemessages -l ja + call_command('makemessages', locale=langs) + +def compilemessages(): + """ po ファイルより、mo ファイルを生成します。 + """ + + # python manage.py compilemessages -l ja + call_command('compilemessages', locale=langs) + +def print_usage(): + print("") + print("Usage) python trans.py [command]") + print("command:") + print(" makemessages: create .po file") + print(" compilemessages: compile .po file (.po -> .mo)") + print("") + +def main(): + # コマンドと対応する関数のマップ + command_map = { + "makemessages": makemessages, + "compilemessages": compilemessages, + "help": print_usage, + } + + # コマンドに対応する関数を実行する + args = sys.argv + command = args[1] if len(args) >= 2 else "help" + command_func = command_map.get(command) + if command_func: + command_func() + +if __name__ == '__main__': + main() +