diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ b/accounts/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AccountsAuthConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'accounts_auth' diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py index a0f5486..8c65794 100644 --- a/log_manager/trace_log.py +++ b/log_manager/trace_log.py @@ -2,6 +2,7 @@ import inspect import logging import threading +import traceback thread_local = threading.local() @@ -71,6 +72,11 @@ "ext_lineno": f_lineno, }, ) + + # トレースバック出力 + traceback_str = traceback.format_exc() + logger.error(f"{traceback_str}") + raise return wrapper diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py index a0f5486..8c65794 100644 --- a/log_manager/trace_log.py +++ b/log_manager/trace_log.py @@ -2,6 +2,7 @@ import inspect import logging import threading +import traceback thread_local = threading.local() @@ -71,6 +72,11 @@ "ext_lineno": f_lineno, }, ) + + # トレースバック出力 + traceback_str = traceback.format_exc() + logger.error(f"{traceback_str}") + raise return wrapper diff --git a/log_manager/views.py b/log_manager/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/log_manager/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py index a0f5486..8c65794 100644 --- a/log_manager/trace_log.py +++ b/log_manager/trace_log.py @@ -2,6 +2,7 @@ import inspect import logging import threading +import traceback thread_local = threading.local() @@ -71,6 +72,11 @@ "ext_lineno": f_lineno, }, ) + + # トレースバック出力 + traceback_str = traceback.format_exc() + logger.error(f"{traceback_str}") + raise return wrapper diff --git a/log_manager/views.py b/log_manager/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/log_manager/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/pydwiki/settings.py b/pydwiki/settings.py index f4c421b..b87aad4 100644 --- a/pydwiki/settings.py +++ b/pydwiki/settings.py @@ -48,7 +48,7 @@ "django_otp", "django_otp.plugins.otp_totp", "accounts", - "accounts_auth", + "jwt_auth", ] MIDDLEWARE = [ @@ -142,8 +142,9 @@ # For Rest Framework REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATIOIN_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication" + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py index a0f5486..8c65794 100644 --- a/log_manager/trace_log.py +++ b/log_manager/trace_log.py @@ -2,6 +2,7 @@ import inspect import logging import threading +import traceback thread_local = threading.local() @@ -71,6 +72,11 @@ "ext_lineno": f_lineno, }, ) + + # トレースバック出力 + traceback_str = traceback.format_exc() + logger.error(f"{traceback_str}") + raise return wrapper diff --git a/log_manager/views.py b/log_manager/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/log_manager/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/pydwiki/settings.py b/pydwiki/settings.py index f4c421b..b87aad4 100644 --- a/pydwiki/settings.py +++ b/pydwiki/settings.py @@ -48,7 +48,7 @@ "django_otp", "django_otp.plugins.otp_totp", "accounts", - "accounts_auth", + "jwt_auth", ] MIDDLEWARE = [ @@ -142,8 +142,9 @@ # For Rest Framework REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATIOIN_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication" + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", diff --git a/pydwiki/urls.py b/pydwiki/urls.py index a63746e..fbf9635 100644 --- a/pydwiki/urls.py +++ b/pydwiki/urls.py @@ -22,5 +22,5 @@ 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")), + path("api/auth/", include("jwt_auth.urls")), ] diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..9410b3e --- /dev/null +++ b/.coverage Binary files differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1dada59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = . +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + +[report] +omit = + */apps.py + */migrations/* + */tests/* + */tests.py + */__init__.py + .venv/* + manage.py + pydwiki/* + trans.py + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e28823d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +django = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71433ce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "test", + "accounts.tests.tests_models_custom_group", + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be405ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./accounts", + "-p", + "*test.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/accounts/admin.py b/accounts/admin.py index e2a35fc..49bdfc1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,13 +1,13 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group 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 +from .models import CustomGroup, CustomUser, EmailAddress class EmailAddressInlineFormSet(BaseInlineFormSet): @@ -17,6 +17,7 @@ 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. ")) @@ -132,3 +133,24 @@ 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) + + +class CustomGroupInline(admin.TabularInline): + """カスタムグループインライン表示用""" + + model = CustomGroup + extra = 1 + fk_name = "group" + verbose_name = _("Group Information") + verbose_name_plural = _("Group Information") + + +class GroupAdmin(admin.ModelAdmin): + inlines = [ + CustomGroupInline, + ] + """ インライン表示。 """ + + +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/accounts/locale/en/LC_MESSAGES/django.po b/accounts/locale/en/LC_MESSAGES/django.po index c1ea489..54b031a 100644 --- a/accounts/locale/en/LC_MESSAGES/django.po +++ b/accounts/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "" + #: accounts/apps.py:8 msgid "accounts" msgstr "" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 msgid "Created at" msgstr "" -#: accounts/models/email_address.py:44 -msgid "{self.email} ({'Primary' if self.is_primary else 'Backup'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" msgstr "" diff --git a/accounts/locale/ja/LC_MESSAGES/django.mo b/accounts/locale/ja/LC_MESSAGES/django.mo index 5d27056..d1134b9 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.mo +++ 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 index c97fddc..8b712e2 100644 --- a/accounts/locale/ja/LC_MESSAGES/django.po +++ b/accounts/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-08 01:07+0900\n" +"POT-Creation-Date: 2025-02-21 14:34+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,31 +18,63 @@ "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: accounts/admin.py:21 +#: accounts/admin.py:23 msgid "Only one primary email address can be set. " msgstr "メインのメールアドレスは、1つだけ指定可能です。" -#: accounts/admin.py:78 +#: accounts/admin.py:82 msgid "Permissions" msgstr "権限" -#: accounts/admin.py:90 +#: accounts/admin.py:94 msgid "Password" msgstr "パスワード" -#: accounts/admin.py:120 +#: accounts/admin.py:124 accounts/tests/tests_admin.py:96 msgid "None" msgstr "なし" -#: accounts/admin.py:122 accounts/models/custom_user.py:47 -#: accounts/models/email_address.py:18 +#: accounts/admin.py:126 accounts/models/custom_user.py:47 +#: accounts/models/email_address.py:20 msgid "Email" msgstr "メールアドレス" +#: accounts/admin.py:144 accounts/admin.py:145 +msgid "Group Information" +msgstr "グループ情報" + #: accounts/apps.py:8 msgid "accounts" msgstr "アカウント" +#: accounts/models/custom_group.py:11 +msgid "Root" +msgstr "/" + +#: accounts/models/custom_group.py:14 +msgid "Organization" +msgstr "組織" + +#: accounts/models/custom_group.py:17 +msgid "Department" +msgstr "部門" + +#: accounts/models/custom_group.py:20 +msgid "Team" +msgstr "チーム" + +#: accounts/models/custom_group.py:23 +msgid "Other" +msgstr "その他" + +#: accounts/models/custom_group.py:52 +msgid "Group" +msgstr "グループ" + +#: accounts/models/custom_group.py:55 +msgid "Groups" +msgstr "グループ" + #: accounts/models/custom_user.py:27 msgid "Login ID" msgstr "ログイン ID" @@ -89,6 +121,10 @@ msgid "MFA" msgstr "MFA 有効" +#: accounts/models/custom_user.py:139 accounts/models/custom_user.py:142 +msgid "USER" +msgstr "ユーザー" + #: accounts/models/custom_user_manager.py:24 msgid "Login ID is required" msgstr "ログイン ID は、必須です。" @@ -105,18 +141,18 @@ msgid "Superuser must have is_superuser=True." msgstr "管理者は、管理者権限が必須です。" -#: accounts/models/email_address.py:14 +#: accounts/models/email_address.py:16 msgid "User" msgstr "ユーザー" -#: accounts/models/email_address.py:21 +#: accounts/models/email_address.py:23 msgid "Primary" msgstr "メイン" -#: accounts/models/email_address.py:24 +#: accounts/models/email_address.py:26 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 '予備'})" +#: accounts/models/email_address.py:56 accounts/models/email_address.py:59 +msgid "EMAIL" +msgstr "メールアドレス" diff --git a/accounts/migrations/0002_alter_customuser_is_staff.py b/accounts/migrations/0002_alter_customuser_is_staff.py new file mode 100644 index 0000000..c177734 --- /dev/null +++ b/accounts/migrations/0002_alter_customuser_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-12 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_staff', + field=models.BooleanField(default=False, verbose_name='Staff'), + ), + ] diff --git a/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py new file mode 100644 index 0000000..a8f2c72 --- /dev/null +++ b/accounts/migrations/0003_alter_customuser_options_alter_emailaddress_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_alter_customuser_is_staff'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'verbose_name': 'USER', 'verbose_name_plural': 'USER'}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'verbose_name': 'EMAIL', 'verbose_name_plural': 'EMAIL'}, + ), + migrations.CreateModel( + name='CustomGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='info', to='auth.group', verbose_name='Group')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='auth.group', verbose_name='Parent')), + ], + options={ + 'verbose_name': 'Group', + 'verbose_name_plural': 'Groups', + }, + ), + ] diff --git a/accounts/migrations/0004_customgroup_group_type.py b/accounts/migrations/0004_customgroup_group_type.py new file mode 100644 index 0000000..1c37274 --- /dev/null +++ b/accounts/migrations/0004_customgroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-21 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_customuser_options_alter_emailaddress_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='customgroup', + name='group_type', + field=models.CharField(choices=[('ROOT', 'Root'), ('Organization', 'Organization'), ('Department', 'Department'), ('Team', 'Team'), ('Other', 'Other')], default='Other', max_length=16), + ), + ] diff --git a/accounts/models/__init__.py b/accounts/models/__init__.py index de31106..1078495 100644 --- a/accounts/models/__init__.py +++ b/accounts/models/__init__.py @@ -1,10 +1,13 @@ -from .custom_user_manager import CustomUserManager +from .custom_group import CustomGroup from .custom_user import CustomUser +from .custom_user_manager import CustomUserManager from .email_address import EmailAddress - +from .integer_choices_field import IntegerChoicesField __all__ = [ - "CustomUserManager", + "CustomGroup", "CustomUser", + "CustomUserManager", "EmailAddress", + "IntegerChoicesField", ] diff --git a/accounts/models/custom_group.py b/accounts/models/custom_group.py new file mode 100644 index 0000000..23b9482 --- /dev/null +++ b/accounts/models/custom_group.py @@ -0,0 +1,240 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from log_manager.trace_log import trace_log + +from .integer_choices_field import IntegerChoicesField + + +class CustomGroupQuerySet(models.QuerySet): + """カスタムグループ用マネージャ。""" + + @trace_log + def create(self, **kwargs): + """CustomGroup を生成します。 + CustomGroup.objects.create(name="New Group") を呼び出すと、自動的に Group も作成されます。 + + Arguments + kwargs: パラメータ + + Return: + 生成したオブジェクト + """ + if "group" not in kwargs: + # group 指定がない場合、name より、Group を作成する。 + name = kwargs.pop("name", None) + if name: + group = Group.objects.create(name=name) + kwargs["group"] = group + else: + raise ValueError(_("The 'name' parameter is required to create a Group.")) + + # group_type と parent は kwargs から取得(指定がなければデフォルト値) + group_type = kwargs.pop("group_type", self.model.GroupType.OTHER) + kwargs["group_type"] = group_type + + instance = self.model(**kwargs) + instance.full_clean() + instance.save(force_insert=True) + return instance + + def get(self, **kwargs): + lookup_kwargs = kwargs.copy() + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + return super().get(**lookup_kwargs) + + @trace_log + def update(self, **kwargs): + """CustomGroup を更新します。 + name キーが指定されている場合は、Group.name も更新します。 + ※ bulk update であるため、シグナル等は発火しません。 + """ + if "name" in kwargs: + new_name = kwargs.pop("name") + for obj in self: + obj.group.name = new_name + obj.group.save() + return super().update(**kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を取得します。 + 取得できない場合は、新しく作成します。 + """ + defaults = defaults or {} + lookup_kwargs = kwargs.copy() + + if "name" in lookup_kwargs: + lookup_kwargs["group__name"] = lookup_kwargs.pop("name") + + try: + obj = self.get(**lookup_kwargs) + return obj, False + except self.model.DoesNotExist: + if "name" not in kwargs and "group__name" in lookup_kwargs: + kwargs["name"] = lookup_kwargs["group__name"] + del kwargs["group__name"] + return self.create(**{**kwargs, **defaults}), True + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + """指定された、CustomGroup を更新します。""" + defaults = defaults or {} + obj, created = self.get_or_create(defaults=defaults, **kwargs) + if not created: + if "name" in defaults: + new_name = defaults.pop("name") + obj.group.name = new_name + obj.group.save() + # 残りの defaults で obj を更新する。 + for attr, value in defaults.items(): + setattr(obj, attr, value) + obj.full_clean() + obj.save() + return obj, created + + @trace_log + def filter(self, *args, **kwargs): + """指定された、CustomGroup を取得します。""" + if "name" in kwargs: + kwargs["group__name"] = kwargs.pop("name") + return super().filter(*args, **kwargs) + + @trace_log + def delete(self): + """CustomGroup を削除します。 + QuerySet.delet() は、bulk 操作で delete() が呼ばれないため、 + 個々のインスタンスの delete を呼び出すことで、関連 Group の削除も実行します。 + """ + for obj in self: + obj.delete() + + return super().delete() + + +class CustomGroupManager(models.Manager): + @trace_log + def get_queryset(self): + return CustomGroupQuerySet(self.model, using=self._db) + + @trace_log + def create(self, **kwargs): + return self.get_queryset().create(**kwargs) + + @trace_log + def update(self, **kwargs): + """CusgomGroup オブジェクトを更新します。 + name が指定されている場合は、Group.name も更新します。 + 一括更新を実行するため、シグナルはトリガーされません。 + + Arguments: + kwargs: 更新用パラメータ + + Return: + 更新後オブジェクト数 + """ + return self.get_queryset().update(**kwargs) + + @trace_log + def update_or_create(self, defaults=None, **kwargs): + return self.get_queryset().update_or_create(defaults=defaults, **kwargs) + + @trace_log + def get_or_create(self, defaults=None, **kwargs): + return self.get_queryset().get_or_create(defaults=defaults, **kwargs) + + @trace_log + def filter(self, *args, **kwargs): + return self.get_queryset().filter(*args, **kwargs) + + @trace_log + def delete(self): + return self.get_queryset().delete() + + +class CustomGroup(models.Model): + + objects = CustomGroupManager() + """ カスタムグループ用マネージャ。 """ + + class GroupType(models.IntegerChoices): + """グループ種別。""" + + ROOT = 0, _("Root") + """ ルート。 """ + + ORGANIZATION = 1, _("Organization") + """ 組織。 """ + + DEPARTMENT = 2, _("Department") + """ 部門。 """ + + TEAM = 3, _("Team") + """ チーム。 """ + + OTHER = 99, _("Other") + """ その他。 """ + + group = models.OneToOneField( + Group, on_delete=models.CASCADE, related_name="info", verbose_name="Group" + ) + """ グループ """ + + group_type = IntegerChoicesField( + # group_type = models.IntegerField( + choices=GroupType.choices, + default=GroupType.OTHER, + ) + """ グループ種別 """ + + parent = models.ForeignKey( + "self", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="children", + verbose_name="Parent", + ) + """ 親グループ """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Group") + """ 本モデルの名称。 """ + + verbose_name_plural = _("Groups") + """ 本モデルの複数形の名称 """ + + @trace_log + def clean(self): + """parent が自分自身を参照していないか確認する。""" + if self.parent: + self._check_parent_loop(self.parent) + + def save(self, *args, **kwargs): + """save の前に、clean によるチェックを実施する。""" + self.full_clean() + return super().save(*args, **kwargs) + + def _check_parent_loop(self, parent): + """親がさらに上の親としてグループを参照していないかチェックする。""" + if parent == self: + raise ValidationError(_("A group cannot be its own parent.")) + if parent.parent: + self._check_parent_loop(parent.parent) + + def __str__(self) -> str: + """ + 文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + if self.parent: + return f"{str(self.parent)}/{self.group.name}" + return self.group.name diff --git a/accounts/models/custom_user.py b/accounts/models/custom_user.py index 6341780..47e969b 100644 --- a/accounts/models/custom_user.py +++ b/accounts/models/custom_user.py @@ -58,7 +58,7 @@ """ 有効/無効。 """ is_staff = models.BooleanField( - default=True, + default=False, verbose_name=_("Staff"), ) """ スタッフか否か。 """ @@ -136,8 +136,8 @@ return self.username class Meta: - verbose_name = "ユーザー" + verbose_name = _("USER") """ 本モデルの名称。 """ - verbose_name_plural = "ユーザー" + verbose_name_plural = _("USER") """ 本モデルの複数形の名称 """ diff --git a/accounts/models/email_address.py b/accounts/models/email_address.py index 57460ee..bf4e453 100644 --- a/accounts/models/email_address.py +++ b/accounts/models/email_address.py @@ -3,6 +3,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from log_manager.trace_log import trace_log + class EmailAddress(models.Model): """メールアドレス""" @@ -24,6 +26,7 @@ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) """ 生成日時 """ + @trace_log def save(self, *args, **kwargs): """保存の際呼び出されます。 is_primary=True が指定されている場合、 対象ユーザーの他の is_primary を全て False にします。 @@ -33,6 +36,16 @@ EmailAddress.objects.filter(user=self.user, is_primary=True).update(is_primary=False) super().save(*args, **kwargs) + @trace_log + def __str__(self): + """文字列表現を返します。 + + Return: + 本モデルの文字列表現 + """ + add_info = "primary" if self.is_primary else "" + return f"{self.email} ({add_info})" + class Meta: constraints = [ models.UniqueConstraint( @@ -40,5 +53,8 @@ ) ] - def __str__(self): - return _(f"{self.email} ({'Primary' if self.is_primary else 'Backup'})") + verbose_name = _("EMAIL") + """ 本モデルの名称。 """ + + verbose_name_plural = _("EMAIL") + """ 本モデルの複数形の名称 """ diff --git a/accounts/models/integer_choices_field.py b/accounts/models/integer_choices_field.py new file mode 100644 index 0000000..d243619 --- /dev/null +++ b/accounts/models/integer_choices_field.py @@ -0,0 +1,14 @@ +from django.db import models + + +class IntegerChoicesField(models.IntegerField): + + def from_db_value(self, value, expressin, connection): + if value is None: + return value + return int(value) + + def to_python(self, value): + if value is None: + return value + return super().to_python(value) diff --git a/accounts/permissions.py b/accounts/permissions.py new file mode 100644 index 0000000..7863153 --- /dev/null +++ b/accounts/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from log_manager.trace_log import trace_log + + +class IsCustomPerrmission(permissions.BasePermission): + + @trace_log + def has_permission(self, request, view): + return request.user.is_staff diff --git a/accounts/serializers.py b/accounts/serializers.py index 84b1025..f3238e7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,5 +1,8 @@ +from django.utils import timezone from rest_framework import serializers +from log_manager.trace_log import trace_log + from .models import CustomUser, EmailAddress @@ -10,11 +13,12 @@ model = EmailAddress """ 対象モデル """ - fields = ( + fields = [ + "id", "user", "email", "is_primary", - ) + ] """ 対象フィールド """ @@ -22,17 +26,14 @@ """カスタムユーザーシリアライザ。""" emails = EmailAddressSerializer(many=True, read_only=True) - """ メールアドレス(複数) """ - - email_count = serializers.SerializerMethodField() - """ メールアドレス数 """ + """ メールアドレス。(Read Only) """ class Meta: model = CustomUser """ 対象モデル。 """ - fields = ( + fields = [ "id", "login_id", "username", @@ -41,23 +42,36 @@ "is_superuser", "password_changed", "password_changed_date", + "is_mfa_enabled", "emails", - "email_count", - ) + "password", + ] """ 対象フィールド。 """ - # read_only_fiields = ("login_id", ) - def get_email_count(self, obj): - """メールアドレス数を取得します。 + extra_kwargs = { + "is_staff": { + "required": False, + "default": True, + }, + "is_active": { + "required": False, + "default": True, + }, + "is_superuser": { + "required": False, + "default": False, + }, + "is_mfa_enabled": { + "required": False, + "default": False, + }, + "password": { + "write_only": True, + "required": False, + }, + } - Arguments: - obj: 対象インスタンス - - Return: - メールアドレス数 - """ - return obj.emails.count() - + @trace_log def create(self, validated_data): """生成時に呼び出されます。 @@ -67,14 +81,17 @@ Return: 生成したユーザー """ - emails_data = validated_data.pop("emails", []) - user = CustomUser.objects.create(**validated_data) + password = validated_data.pop("password", None) + if password: + user = CustomUser.objects.create(**validated_data) + user.set_password(password) + user.password_changed = False + user.password_changed_date = None + user.save() + return user + return super().create(validated_data) - for email_data in emails_data: - EmailAddress.objects.create(user=user, **email_data) - - return user - + @trace_log def update(self, instance, validated_data): """更新時に呼び出されます。 @@ -85,35 +102,9 @@ 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 + password = validated_data.pop("password", None) + if password: + instance.set_password(password) + instance.password_changed = True + instance.password_changed_date = timezone.now() + return super().update(instance, validated_data) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..fa66513 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,17 @@ +from .tests_admin import EmailAddressInlineFormSetTestCase +from .tests_models_custom_group import CustomGroupTestCase +from .tests_models_custom_user import CustomUserTestCase +from .tests_models_custom_user_manager import CustomUserManagerTestCase +from .tests_models_email_address import EmailAddressTestCase +from .tests_models_integer_choices_fields import IntegerChoicesFieldTestCase +from .tests_serializer import SerializerTestCase + +__all__ = [ + "EmailAddressInlineFormSetTestCase", + "CustomGroupTestCase", + "CustomUserTestCase", + "CustomUserManagerTestCase", + "EmailAddressTestCase", + "IntegerChoicesFieldTestCase", + "SerializerTestCase", +] diff --git a/accounts/tests/tests_admin.py b/accounts/tests/tests_admin.py new file mode 100644 index 0000000..639ac88 --- /dev/null +++ b/accounts/tests/tests_admin.py @@ -0,0 +1,147 @@ +from typing import cast + +from django.forms.models import ModelForm, inlineformset_factory +from django.http import HttpRequest +from django.test import TestCase +from django.utils.translation import gettext_lazy as _ + +from accounts.admin import CustomUserAdmin, EmailAddressInlineFormSet +from accounts.models import CustomUser, CustomUserManager, EmailAddress + + +# CustomUserForm を定義 +class CustomUserForm(ModelForm): + class Meta: + model = CustomUser + fields = [ + "login_id", + "username", + "password", + "is_mfa_enabled", + "is_staff", + "is_active", + "is_superuser", + "_email", + ] + + +class EmailAddressInlineFormSetTestCase(TestCase): + + def setUp(self): + self.user_manager = cast(CustomUserManager, CustomUser.objects) + self.user = self.user_manager.create_user( + login_id="testuser", + _email="testuser@example.com", + username="Test User", + password="password", + ) + EmailAddress.objects.create(user=self.user, email="testuser@dummy.jp", is_primary=False) + + self.EmailAddressInlineFormSet = inlineformset_factory( + CustomUser, + EmailAddress, + formset=EmailAddressInlineFormSet, + fields=["user", "is_primary", "email"], + extra=1, + ) + + def test_clean(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": False, + "emails-1-email": "testuser2@example.com", + }, + ) + + # データが正しいため True + self.assertTrue(formset.is_valid()) + + def test_clean_two_primary(self): + formset = self.EmailAddressInlineFormSet( + instance=self.user, + data={ + "csrfmiddlewaretoken": "tCicwCxx4nMwxeScssSSic9csLIah4pbqDoHTAbsRHZfMfgVfrKIL4CyAR6CvAoG", + "emails-TOTAL_FORMS": 2, + "emails-INITIAL_FORMS": 0, + "emails-MIN_NUM_FORMS": 0, + "emails-MAX_NUM_FORMS": 1000, + "emails-0-user": self.user.pk, + "emails-0-is_primary": True, + "emails-0-email": "testuser1@example.com", + "emails-1-user": self.user.pk, + "emails-1-is_primary": True, + "emails-1-email": "testuser2@example.com", + }, + ) + + # Primary が 2 つあるため False + self.assertFalse(formset.is_valid()) + + def test_get_primary_email(self): + admin = CustomUserAdmin(CustomUser, None) + email = admin.get_primary_email(self.user) + self.assertEqual(email, self.user.emails.filter(is_primary=True).first().email) + + self.user.emails.filter(is_primary=True).update(is_primary=False) + email = admin.get_primary_email(self.user) + self.assertEqual(email, _("None")) + + def test_get_save_model(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) + + def test_get_save_model_exists_email(self): + admin = CustomUserAdmin(CustomUser, None) + + request = HttpRequest() + request.method = "POST" + request.path = "/admin/accounts/customuser/add/" + request.POST.setlist("login_id", ["testuser"]) + request.POST.setlist("username", ["Test User"]) + request.POST.setlist("password", ["password"]) + request.POST.setlist("is_mfa_enabled", ["off"]) + request.POST.setlist("_email", ["testuser@sample.com"]) + request.POST.setlist("emails-TOTAL_FORMS", ["2"]) + request.POST.setlist("emails-INITIAL_FORMS", ["0"]) + request.POST.setlist("emails-MIN_NUM_FORMS", ["0"]) + request.POST.setlist("emails-MAX_NUM_FORMS", ["1000"]) + request.POST.setlist("emails-0-user", [str(self.user.pk)]) + request.POST.setlist("emails-0-is_primary", ["on"]) + request.POST.setlist("emails-0-email", ["testuser1@example.com"]) + request.POST.setlist("emails-1-user", [self.user.pk]) + request.POST.setlist("emails-1-is_primary", ["off"]) + request.POST.setlist("emails-1-email", ["testuser2@example.com"]) + + form = CustomUserForm(request.POST) + form.is_valid() + admin.save_model(request, self.user, form, True) diff --git a/accounts/tests/tests_models_custom_group.py b/accounts/tests/tests_models_custom_group.py new file mode 100644 index 0000000..eeba27b --- /dev/null +++ b/accounts/tests/tests_models_custom_group.py @@ -0,0 +1,182 @@ +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from accounts.models import CustomGroup + + +class CustomGroupTestCase(TestCase): + + def test_str(self): + group = Group.objects.create(name="Test Group") + custom_group = CustomGroup.objects.create(group=group) + self.assertEqual(str(custom_group), "Test Group") + + def test_parent(self): + parent_group = CustomGroup.objects.create(name="Parent Group") + child_group = CustomGroup.objects.create(name="Child Group", parent=parent_group) + self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_parent_loop_error(self): + parent1_group = CustomGroup.objects.create(name="Parent1 Group") + parent2_group = CustomGroup.objects.create(name="Parent2 Group", parent=parent1_group) + child_group = CustomGroup.objects.create(name="Child Group", parent=parent2_group) + parent1_group.parent = child_group # type: ignore + with self.assertRaises(ValidationError): + parent1_group.save() + + # self.assertEqual(str(child_group), "Parent Group/Child Group") + + def test_create_custom_group(self): + cg = CustomGroup.objects.create(name="Test Group") + self.assertIsNotNone(cg) + self.assertEqual(cg.group.name, "Test Group") + + def test_create_custom_group_noname(self): + with self.assertRaises(ValueError): + CustomGroup.objects.create() + + def test_update_custom_group(self): + cg1 = CustomGroup.objects.create(name="Test1 Group") + cg2 = CustomGroup.objects.create(name="Test2 Group") + cg3 = CustomGroup.objects.create(name="Test3 Group") + CustomGroup.objects.filter(name="Test1 Group").update(name="Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg1.group.pk).group.name, "Test Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg2.group.pk).group.name, "Test2 Group") + self.assertEqual(CustomGroup.objects.get(group__pk=cg3.group.pk).group.name, "Test3 Group") + + def test_update_custom_group_duplicate_names(self): + CustomGroup.objects.create(name="Test1 Group") + CustomGroup.objects.create(name="Test2 Group") + CustomGroup.objects.create(name="Test3 Group") + with self.assertRaises(IntegrityError): + CustomGroup.objects.all().update(name="Test Group") + + def test_update_custom_group_noname(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.all().update(group_type=CustomGroup.GroupType.TEAM) + groups = CustomGroup.objects.all() + for group in groups: + self.assertEqual(group.group_type, CustomGroup.GroupType.TEAM) + + def test_get(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + + searched_cg1 = CustomGroup.objects.get(name="Test1 Group") + self.assertEqual(searched_cg1, cg1) + + searched_cg2 = CustomGroup.objects.get(group__name="Test2 Group") + self.assertEqual(searched_cg2, cg2) + + def test_get_or_create(self): + cg1, created1 = CustomGroup.objects.get_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.get_or_create(name="Test1 Group") + self.assertFalse(created2) + self.assertEqual(cg2, cg1) + + cg3, created3 = CustomGroup.objects.get_or_create(group__name="Test1 Group") + self.assertFalse(created3) + self.assertEqual(cg3, cg1) + + cg4, created4 = CustomGroup.objects.get_or_create(group__name="Test2 Group") + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.OTHER) + + def test_update_or_create(self): + cg1, created1 = CustomGroup.objects.update_or_create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertTrue(created1) + + cg2, created2 = CustomGroup.objects.update_or_create( + name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created2) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg2.pk, cg1.pk) + + cg3, created3 = CustomGroup.objects.update_or_create( + group__name="Test1 Group", + defaults={ + "group_type": CustomGroup.GroupType.TEAM, + }, + ) + self.assertFalse(created3) + self.assertEqual(cg3.group_type, CustomGroup.GroupType.TEAM) + self.assertEqual(cg3.pk, cg1.pk) + + cg4, created4 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertTrue(created4) + self.assertEqual(cg4.group.name, "Test2 Group") + self.assertEqual(cg4.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertNotEqual(cg4.pk, cg1.pk) + + cg5, created5 = CustomGroup.objects.update_or_create( + group__name="Test2 Group", + defaults={ + "name": "Test5 Group", + "group_type": CustomGroup.GroupType.DEPARTMENT, + }, + ) + self.assertFalse(created5) + self.assertEqual(cg5.group.name, "Test5 Group") + self.assertEqual(cg5.group_type, CustomGroup.GroupType.DEPARTMENT) + self.assertEqual(cg5.pk, cg4.pk) + + def test_update(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.update(group_type=CustomGroup.GroupType.TEAM) + cgs = CustomGroup.objects.all() + for cg in cgs: + self.assertEqual(cg.group_type, CustomGroup.GroupType.TEAM) + + def test_delete(self): + cg1 = CustomGroup.objects.create( + name="Test1 Group", group_type=CustomGroup.GroupType.ORGANIZATION + ) + self.assertEqual(cg1.group_type, CustomGroup.GroupType.ORGANIZATION) + + cg2 = CustomGroup.objects.create( + name="Test2 Group", group_type=CustomGroup.GroupType.DEPARTMENT + ) + self.assertEqual(cg2.group_type, CustomGroup.GroupType.DEPARTMENT) + + CustomGroup.objects.delete() + self.assertEqual(CustomGroup.objects.count(), 0) diff --git a/accounts/tests/tests_models_custom_user.py b/accounts/tests/tests_models_custom_user.py new file mode 100644 index 0000000..24036c3 --- /dev/null +++ b/accounts/tests/tests_models_custom_user.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class CustomUserTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + EmailAddress.objects.create(user=self.user, email="testuser1@example.com", is_primary=False) + EmailAddress.objects.create(user=self.user, email="testuser2@example.com", is_primary=True) + + def test_str(self): + self.assertEqual(str(self.user), "Test User") + + def test_get_full_name(self): + self.assertEqual(self.user.get_full_name(), "Test User") + + def test_get_short_name(self): + self.assertEqual(self.user.get_short_name(), "Test User") + + def test_get_primary_email(self): + self.assertEqual(self.user.get_primary_email(), "testuser2@example.com") diff --git a/accounts/tests/tests_models_custom_user_manager.py b/accounts/tests/tests_models_custom_user_manager.py new file mode 100644 index 0000000..aa0e490 --- /dev/null +++ b/accounts/tests/tests_models_custom_user_manager.py @@ -0,0 +1,57 @@ +from typing import cast + +from django.test import TestCase + +from accounts.models import CustomUser +from accounts.models.custom_user_manager import CustomUserManager + + +class CustomUserManagerTestCase(TestCase): + + def setUp(self): + self.user_manager: CustomUserManager = cast(CustomUserManager, CustomUser.objects) + + def test_create_user(self): + user = self.user_manager.create_user( + login_id="testuser", _email="test@example.com", password="password123" + ) + self.assertIsInstance(user, CustomUser) + self.assertTrue(user.emails.filter(email="test@example.com").exists()) + self.assertTrue(user.check_password("password123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + superuser = self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="admin123" + ) + self.assertIsInstance(superuser, CustomUser) + self.assertTrue(superuser.emails.filter(email="admin@example.com").exists()) + self.assertTrue(superuser.check_password("admin123")) + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_create_user_without_login_id(self): + with self.assertRaises(ValueError): + self.user_manager.create_user( + login_id="", _email="user@example.com", password="password123" + ) + + def test_create_user_without_email(self): + with self.assertRaises(ValueError): + self.user_manager.create_user(login_id="user123", _email="", password="password123") + + def test_create_superuser_is_staff_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", _email="admin@example.com", password="password123", is_staff=False + ) + + def test_create_superuser_is_superuser_false(self): + with self.assertRaises(ValueError): + self.user_manager.create_superuser( + login_id="admin", + _email="admin@example.com", + password="password123", + is_superuser=False, + ) diff --git a/accounts/tests/tests_models_email_address.py b/accounts/tests/tests_models_email_address.py new file mode 100644 index 0000000..ab062ee --- /dev/null +++ b/accounts/tests/tests_models_email_address.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress + + +class EmailAddressTestCase(TestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, email="testuser@example.com", is_primary=True + ) + + def test_str(self): + self.assertEqual(str(self.email1), "testuser@example.com (primary)") diff --git a/accounts/tests/tests_models_integer_choices_fields.py b/accounts/tests/tests_models_integer_choices_fields.py new file mode 100644 index 0000000..13213a2 --- /dev/null +++ b/accounts/tests/tests_models_integer_choices_fields.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from accounts.models import IntegerChoicesField + + +class IntegerChoicesFieldTestCase(TestCase): + + def test_from_db_value(self): + val = IntegerChoicesField().from_db_value(None, None, None) + self.assertIsNone(val) + + def test_to_python(self): + val = IntegerChoicesField().to_python(None) + self.assertIsNone(val) diff --git a/accounts/tests/tests_serializer.py b/accounts/tests/tests_serializer.py new file mode 100644 index 0000000..db6e3d5 --- /dev/null +++ b/accounts/tests/tests_serializer.py @@ -0,0 +1,159 @@ +from django.test import TestCase + +from accounts.models import CustomUser, EmailAddress +from accounts.serializers import CustomUserSerializer, EmailAddressSerializer + + +class SerializerTestCase(TestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_email_address_serializer(self): + """EmailAddressSerializer のシリアライズ動作を確認する。""" + serializer = EmailAddressSerializer(instance=self.email1) + expected_data = { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer(self): + """CustomUserSerializer のシリアライズ動作を確認する。""" + serializer = CustomUserSerializer(instance=self.user) + expected_data = { + "id": self.user.id, # type: ignore + "login_id": "testuser", + "username": "Test User", + "is_staff": True, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "password_changed_date": None, + "is_mfa_enabled": False, + "emails": [ + { + "id": self.email1.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test1@example.com", + "is_primary": True, + }, + { + "id": self.email2.id, # type: ignore + "user": self.user.id, # type: ignore + "email": "test2@example.com", + "is_primary": False, + }, + ], + } + self.assertEqual(serializer.data, expected_data) + + def test_custom_user_serializer_create(self): + """CustomUserSerializer を使用してユーザーの生成を確認する。""" + data = { + "login_id": "newuser", + "username": "New User", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser") + + def test_custom_user_serializer_create_password(self): + """CustomUserSerializer を使用してユーザーを作成、パスワードを設定を確認する。""" + data = { + "login_id": "newuser2", + "username": "New User2", + "is_staff": False, + "is_active": True, + "is_superuser": False, + "password_changed": False, + "is_mfa_enabled": False, + "password": "samplepass", + } + serializer = CustomUserSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.login_id, "newuser2") + self.assertEqual(user.password_changed, False) + self.assertTrue(user.check_password("samplepass")) + + def test_custom_user_serializer_update(self): + """CustomUserSerializer を使用してユーザー情報更新を確認する。""" + data = { + "username": "Updated User", + "is_staff": False, + } + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User") + self.assertEqual(user.is_staff, False) + + def test_custom_user_serializer_update_password(self): + """ユーザーのパスワードを更新を確認する。""" + data = {"username": "Updated User2", "password": "updatepass"} + serializer = CustomUserSerializer(instance=self.user, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + user = serializer.save() + self.assertEqual(user.username, "Updated User2") + self.assertTrue(user.check_password("updatepass")) + + def test_email_address_serializer_update(self): + """メールアドレスを更新を確認する。""" + data = { + "email": "test2@example2.com", + } + serializer = EmailAddressSerializer(instance=self.email2, data=data, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + email = serializer.save() + self.assertEqual(email.id, self.email2.id) # type: ignore + self.assertEqual(email.user.id, self.user.id) # type: ignore + self.assertEqual(email.email, "test2@example2.com") + + def test_email_address_serializer_update_err_same_address(self): + """既に存在するメールアドレスで更新できないことを確認する。""" + data = { + "email": "test2@example.com", + } + serializer = EmailAddressSerializer(instance=self.email1, data=data, partial=True) + self.assertFalse(serializer.is_valid(), serializer.errors) diff --git a/accounts/tests/tests_views.py b/accounts/tests/tests_views.py new file mode 100644 index 0000000..d0e7cc9 --- /dev/null +++ b/accounts/tests/tests_views.py @@ -0,0 +1,61 @@ +from typing import cast + +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser +from accounts.models.email_address import EmailAddress + + +class CustomPermissionTestCase(APITestCase): + + def setUp(self): + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + password_changed=False, + ) + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.user.set_password("password") + self.user.save() + + def test_custom_user(self): + + # 未認証でのアクセス + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 401) + + # 認証処理 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail("Failed to get access token") + + users_uri = reverse("users-list") + response = self.client.get(users_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py index eaf25a3..33e9c4b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,8 +4,8 @@ from .views import CustomUserViewSet, EmailAddressViewSet router = DefaultRouter() -router.register(r"users", CustomUserViewSet) -router.register(r"emailaddress", EmailAddressViewSet) +router.register(r"users", CustomUserViewSet, basename="users") +router.register(r"emailaddress", EmailAddressViewSet, basename="emailaddress") urlpatterns = [ path("", include(router.urls)), diff --git a/accounts/views.py b/accounts/views.py index 9404a9e..ecd5da0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,22 @@ from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication from .models import CustomUser, EmailAddress +from .permissions import IsCustomPerrmission from .serializers import CustomUserSerializer, EmailAddressSerializer class CustomUserViewSet(viewsets.ModelViewSet): queryset = CustomUser.objects.all() serializer_class = CustomUserSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] class EmailAddressViewSet(viewsets.ModelViewSet): queryset = EmailAddress.objects.all() serializer_class = EmailAddressSerializer + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsCustomPerrmission] diff --git a/accounts_auth/__init__.py b/accounts_auth/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/accounts_auth/__init__.py +++ /dev/null diff --git a/accounts_auth/admin.py b/accounts_auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts_auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts_auth/apps.py b/accounts_auth/apps.py deleted file mode 100644 index 0a0e538..0000000 --- a/accounts_auth/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 71cbdf3..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/en/LC_MESSAGES/django.po b/accounts_auth/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 1c1ee4f..0000000 --- a/accounts_auth/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 083242f..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.mo +++ /dev/null Binary files differ diff --git a/accounts_auth/locale/ja/LC_MESSAGES/django.po b/accounts_auth/locale/ja/LC_MESSAGES/django.po deleted file mode 100644 index 4a0ec3b..0000000 --- a/accounts_auth/locale/ja/LC_MESSAGES/django.po +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 71a8362..0000000 --- a/accounts_auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/accounts_auth/serializers.py b/accounts_auth/serializers.py deleted file mode 100644 index 2c4a3ba..0000000 --- a/accounts_auth/serializers.py +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 7ce503c..0000000 --- a/accounts_auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts_auth/urls.py b/accounts_auth/urls.py deleted file mode 100644 index a6bb7b1..0000000 --- a/accounts_auth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 7a5d445..0000000 --- a/accounts_auth/views.py +++ /dev/null @@ -1,75 +0,0 @@ -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/coverage.sh b/coverage.sh new file mode 100755 index 0000000..69cff14 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# SETUP: +# pip install coverage +# +# coverage run --source='.' manage.py test +IS_CLEAN=$1 +if [ "${IS_CLEAN}" == "clean" ]; then + rm -f .coverage + rm -rf htmlcov +fi +coverage run manage.py test +coverage html +coverage report diff --git a/docs/mkdocs.sh b/docs/mkdocs.sh old mode 100755 new mode 100644 index f11bf58..8073c4e --- a/docs/mkdocs.sh +++ b/docs/mkdocs.sh @@ -5,6 +5,6 @@ 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-apidoc -o docs/source . "**/migrations" "trans" "**/tests") (cd .. && sphinx-build -v -b html -E docs/source/ docs/build) diff --git a/docs/source/accounts.rst b/docs/source/accounts.rst index f99115e..748f188 100644 --- a/docs/source/accounts.rst +++ b/docs/source/accounts.rst @@ -36,14 +36,6 @@ :undoc-members: :show-inheritance: -accounts.tests module ---------------------- - -.. automodule:: accounts.tests - :members: - :undoc-members: - :show-inheritance: - accounts.urls module -------------------- diff --git a/docs/source/accounts_auth.rst b/docs/source/accounts_auth.rst deleted file mode 100644 index 4fe1708..0000000 --- a/docs/source/accounts_auth.rst +++ /dev/null @@ -1,69 +0,0 @@ -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/jwt_auth.rst b/docs/source/jwt_auth.rst new file mode 100644 index 0000000..d4eec83 --- /dev/null +++ b/docs/source/jwt_auth.rst @@ -0,0 +1,61 @@ +jwt\_auth package +================= + +Submodules +---------- + +jwt\_auth.admin module +---------------------- + +.. automodule:: jwt_auth.admin + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.apps module +--------------------- + +.. automodule:: jwt_auth.apps + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.models module +----------------------- + +.. automodule:: jwt_auth.models + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.serializers module +---------------------------- + +.. automodule:: jwt_auth.serializers + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.urls module +--------------------- + +.. automodule:: jwt_auth.urls + :members: + :undoc-members: + :show-inheritance: + +jwt\_auth.views module +---------------------- + +.. automodule:: jwt_auth.views + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: jwt_auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/log_manager.rst b/docs/source/log_manager.rst index 88bf13b..18e7188 100644 --- a/docs/source/log_manager.rst +++ b/docs/source/log_manager.rst @@ -4,14 +4,6 @@ Submodules ---------- -log\_manager.admin module -------------------------- - -.. automodule:: log_manager.admin - :members: - :undoc-members: - :show-inheritance: - log\_manager.apps module ------------------------ @@ -28,14 +20,6 @@ :undoc-members: :show-inheritance: -log\_manager.models module --------------------------- - -.. automodule:: log_manager.models - :members: - :undoc-members: - :show-inheritance: - log\_manager.tests module ------------------------- @@ -52,14 +36,6 @@ :undoc-members: :show-inheritance: -log\_manager.views module -------------------------- - -.. automodule:: log_manager.views - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/source/modules.rst b/docs/source/modules.rst index 8ad6fe2..6e19022 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -5,7 +5,7 @@ :maxdepth: 4 accounts - accounts_auth + jwt_auth log_manager manage pydwiki diff --git a/jwt_auth/__init__.py b/jwt_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/__init__.py diff --git a/jwt_auth/admin.py b/jwt_auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/jwt_auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/jwt_auth/apps.py b/jwt_auth/apps.py new file mode 100644 index 0000000..c1e0b9c --- /dev/null +++ b/jwt_auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AccountsAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "jwt_auth" + verbose_name = _("JWT Authentication") diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.mo b/jwt_auth/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..71cbdf3 --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/en/LC_MESSAGES/django.po b/jwt_auth/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..f128e8f --- /dev/null +++ b/jwt_auth/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "" diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.mo b/jwt_auth/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 0000000..083242f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.mo Binary files differ diff --git a/jwt_auth/locale/ja/LC_MESSAGES/django.po b/jwt_auth/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..25ba06f --- /dev/null +++ b/jwt_auth/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,31 @@ +# 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-21 14:34+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" + +#: jwt_auth/apps.py:8 +msgid "JWT Authentication" +msgstr "" + +#: jwt_auth/serializers.py:44 +msgid "OTP required or invalid OTP." +msgstr "OTP が必要、または、OTP の値が無効です。" + +#: jwt_auth/serializers.py:74 +msgid "OTP must be a 6-digit number." +msgstr "OTP には、6桁の数値を指定する必要があります。" diff --git a/jwt_auth/models.py b/jwt_auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/jwt_auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/jwt_auth/serializers.py b/jwt_auth/serializers.py new file mode 100644 index 0000000..6b2ca3b --- /dev/null +++ b/jwt_auth/serializers.py @@ -0,0 +1,76 @@ +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") + if not self.is_valid_otp(user, otp): + raise serializers.ValidationError({"otp": _("OTP required or invalid OTP.")}) + return data + + @trace_log + def is_valid_otp(self, user: CustomUser, otp) -> bool: + """OTP が有効かどうかを返す。 + + Arguments: + otp: OTP + + Return: + 有効な場合、True + """ + if otp: + devices = TOTPDevice.objects.filter(user=user) + for device in devices: + if device and device.verify_token(otp): + return True + return False + + +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/jwt_auth/tests/__init__.py b/jwt_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jwt_auth/tests/__init__.py diff --git a/jwt_auth/tests/tests_serializer.py b/jwt_auth/tests/tests_serializer.py new file mode 100644 index 0000000..ab828aa --- /dev/null +++ b/jwt_auth/tests/tests_serializer.py @@ -0,0 +1,167 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from accounts.models import CustomUser, EmailAddress +from jwt_auth.serializers import CustomTokenObtainPairSerializer + + +class SerializerTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + + def test_custom_token_obtain_pair_serializer(self): + """CustomTokenObtainPairSerializer のシリアライズ動作を確認する。""" + + serializer = CustomTokenObtainPairSerializer( + data={"login_id": "testuser", "password": "password"} + ) + self.assertTrue(serializer.is_valid()) + + tokens = cast(dict, serializer.validated_data) + access_token_str = tokens["access"] + refresh_token_str = tokens["refresh"] + + # トークンをデコードしてペイロードを取得 + access = AccessToken(access_token_str) + refresh = RefreshToken(refresh_token_str) + + # 対象ユーザーのトークンであることを確認 + self.assertEqual(access["user_id"], self.user.pk) + self.assertEqual(refresh["user_id"], self.user.pk) + + def test_custom_token_obtain_pair_serializer_otp(self): + """CustomTokenObtainPairSerializer (OTP あり) のシリアライズ動作を確認する。""" + + # + # MFA の OTP を取得 + # + + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + # OTP(TOTP) 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + otp_url = response.data["otp_url"] + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # + # TOKEN 取得 + # + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": otp}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + self.fail() + + def test_custom_token_obtain_pair_serializer_no_otp(self): + """CustomTokenObtainPairSerializer (OTP 無しエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_custom_token_obtain_pair_serializer_invalid_otp(self): + """CustomTokenObtainPairSerializer (無効なOTPエラー) のシリアライズ動作を確認する。""" + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password", "otp": "123456"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/tests/tests_views.py b/jwt_auth/tests/tests_views.py new file mode 100644 index 0000000..6eac4d5 --- /dev/null +++ b/jwt_auth/tests/tests_views.py @@ -0,0 +1,141 @@ +from typing import cast + +import pyotp +from django.http import HttpResponse +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase + +from accounts.models import CustomUser, EmailAddress + + +class MfaAuthViewSetTestCase(APITestCase): + + def setUp(self): + """テスト用のユーザー、メールアドレスを設定します。 + 次のユーザー、とユーザーに紐づくメールアドレスを設定します。 + + User: + login_id: testuser + username: Test User + is_staff: True + is_active: True + is_superuser: False + is_mfa_enabled: False + password_changed: False + mail: [ + test1@example.com, is_primary: True + test2@example.com, is_primary: False + ] + """ + self.user = CustomUser.objects.create( + login_id="testuser", + username="Test User", + is_staff=True, + is_active=True, + is_superuser=False, + is_mfa_enabled=False, + password_changed=False, + ) + self.user.set_password("password") + self.user.save() + self.email1 = EmailAddress.objects.create( + user=self.user, + email="test1@example.com", + is_primary=True, + ) + self.email2 = EmailAddress.objects.create( + user=self.user, + email="test2@example.com", + is_primary=False, + ) + # アクセストークンを取得する。 + token_uri = reverse("token_obtain_pair") + response = self.client.post( + token_uri, + {"login_id": "testuser", "password": "password"}, + format="json", + ) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if response.data: + self.accessToken = response.data["access"] + self.client: APIClient = cast(APIClient, self.client) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.accessToken) + else: + raise Exception("Failed to get access token") + + # MFA を有効にする + self.user.is_mfa_enabled = True + self.user.save() + + def test_mfa_setup(self): + """ユーザーの MFA を取得する。""" + + # MFA 取得 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + def test_mfa_verify_success(self): + """MFA の検証が成功することをテスト""" + + # MFA 取得 & 生成 + setup_uri = reverse("mfa-setup") + response = self.client.get(setup_uri) + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA setup failed") + + otp_url = response.data["otp_url"] + qr_code = response.data["qr_code"] + self.assertTrue(otp_url.startswith(f"otpauth://totp/{self.user.login_id}?secret=")) + self.assertIsNotNone(qr_code) + + # 生成した TOTP URI から TOTP の値を取得する。 + totp = pyotp.parse_uri(otp_url) + if type(totp) is not pyotp.TOTP: + self.fail("Invalid TOTP URI") + otp = totp.now() + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = cast(Response, response) + if not response.data: + self.fail("MFA failed") + self.assertEqual(response.data["message"], "MFA enabled successfully") + + def test_mfa_verify_failure(self): + """MFA の検証が失敗することをテスト""" + + # MFA 取得 & 生成 + otp = "123456" + + # MFA を検証 + verify_uri = reverse("mfa-verify") + response = self.client.post(verify_uri, {"otp": otp}) + + response = cast(HttpResponse, response) + print(f"{response}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/jwt_auth/urls.py b/jwt_auth/urls.py new file mode 100644 index 0000000..a6bb7b1 --- /dev/null +++ b/jwt_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/jwt_auth/views.py b/jwt_auth/views.py new file mode 100644 index 0000000..0cd6bed --- /dev/null +++ b/jwt_auth/views.py @@ -0,0 +1,79 @@ +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.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +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): + authentication_classes = [JWTAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + queryset = TOTPDevice.objects.none() + + @trace_log + @action(detail=False, methods=["get"]) + def setup(self, request): + """MFA の設定。ログインしているユーザーの MFA QR コードを返します。 + + Arguments: + request: 要求情報 + + Return: + OTP の URL、 QR コードの情報 (base64 形式の png イメージ) + """ + user = request.user + print(f"user: {user}") + device, __ = 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({"otp_url": otp_url, "qr_code": f"data:image/png; base64,{qr_base64}"}) + + @trace_log + @action(detail=False, methods=["post"]) + def verify(self, request): + """MFA の検証をします。""" + 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/log_manager/admin.py b/log_manager/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/log_manager/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/log_manager/models.py b/log_manager/models.py deleted file mode 100644 index 71a8362..0000000 --- a/log_manager/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/log_manager/tests.py b/log_manager/tests.py index 7ce503c..cf50fd2 100644 --- a/log_manager/tests.py +++ b/log_manager/tests.py @@ -1,3 +1,43 @@ from django.test import TestCase -# Create your tests here. +from .trace_log import trace_log + + +class TraceLogTestCase(TestCase): + """TraceLog テストケース。""" + + def setUp(self): + pass + + def test_trace_log(self): + """trace_log デコレーターのテスト。 + + #. 2つの引数をとり、加算した値を返す関数を用意する。 + #. trace_log(<用意した関数>)(1, 2) を実行する。 + #. 実行結果が 3 (加算した値) となること。 + """ + + def mock_function(a: int, b: int): + return a + b + + decorated_function = trace_log(mock_function) + result = decorated_function(1, 2) + self.assertEqual(result, 3) + + def test_trace_log_exception(self): + """trace_log デコレーターの Exception 発生時のテスト。 + + #. ValueError("invalid values") の例外が発生する関数を用意する。 + #. trace_log(<用意した関数>)(<関数への引数>) を実行する。 + #. ValueError("invalid values") が発生すること。 + """ + + def mock_exception_function(a: int, b: int): + raise ValueError("invalid values") + + decorated_function = trace_log(mock_exception_function) + try: + decorated_function(1, 2) + self.fail() + except ValueError as e: + self.assertEqual(str(e), "invalid values") diff --git a/log_manager/trace_log.py b/log_manager/trace_log.py index a0f5486..8c65794 100644 --- a/log_manager/trace_log.py +++ b/log_manager/trace_log.py @@ -2,6 +2,7 @@ import inspect import logging import threading +import traceback thread_local = threading.local() @@ -71,6 +72,11 @@ "ext_lineno": f_lineno, }, ) + + # トレースバック出力 + traceback_str = traceback.format_exc() + logger.error(f"{traceback_str}") + raise return wrapper diff --git a/log_manager/views.py b/log_manager/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/log_manager/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/pydwiki/settings.py b/pydwiki/settings.py index f4c421b..b87aad4 100644 --- a/pydwiki/settings.py +++ b/pydwiki/settings.py @@ -48,7 +48,7 @@ "django_otp", "django_otp.plugins.otp_totp", "accounts", - "accounts_auth", + "jwt_auth", ] MIDDLEWARE = [ @@ -142,8 +142,9 @@ # For Rest Framework REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATIOIN_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication" + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", diff --git a/pydwiki/urls.py b/pydwiki/urls.py index a63746e..fbf9635 100644 --- a/pydwiki/urls.py +++ b/pydwiki/urls.py @@ -22,5 +22,5 @@ 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")), + path("api/auth/", include("jwt_auth.urls")), ] diff --git a/requirements.txt b/requirements.txt index 2abba4d..280247b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,9 @@ certifi==2025.1.31 charset-normalizer==3.4.1 Django==5.1.5 +django-otp==1.5.4 djangorestframework==3.15.2 +djangorestframework_simplejwt==5.5.0 docutils==0.21.2 idna==3.10 imagesize==1.4.1 @@ -12,7 +14,10 @@ MarkupSafe==3.0.2 packaging==24.2 Pygments==2.19.1 +PyJWT==2.9.0 +qrcode==8.0 requests==2.32.3 +rest-framework-simplejwt==0.0.2 snowballstemmer==2.2.0 Sphinx==8.1.3 sphinx-autodoc-typehints==3.0.1