From 2a8ae9ace51d7223451cbfbc52e4e9f33d3c1b81 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:10:16 +0000 Subject: [PATCH 01/10] Install djoser manually --- b2b/djoser/.codacy.yml | 3 + b2b/djoser/__init__.py | 1 + b2b/djoser/compat.py | 12 + b2b/djoser/conf.py | 165 +++++++++ b2b/djoser/constants.py | 14 + b2b/djoser/email.py | 57 +++ b2b/djoser/locale/de/LC_MESSAGES/django.po | 138 +++++++ b2b/djoser/locale/es/LC_MESSAGES/django.po | 189 ++++++++++ b2b/djoser/locale/fr/LC_MESSAGES/django.po | 185 ++++++++++ b2b/djoser/locale/ja/LC_MESSAGES/django.po | 139 +++++++ b2b/djoser/locale/ka/LC_MESSAGES/django.po | 129 +++++++ b2b/djoser/locale/me/LC_MESSAGES/django.po | 133 +++++++ b2b/djoser/locale/pl/LC_MESSAGES/django.po | 139 +++++++ b2b/djoser/locale/pt_BR/LC_MESSAGES/django.po | 140 +++++++ b2b/djoser/locale/ru_RU/LC_MESSAGES/django.po | 141 ++++++++ b2b/djoser/permissions.py | 16 + b2b/djoser/serializers.py | 341 ++++++++++++++++++ b2b/djoser/signals.py | 10 + b2b/djoser/social/__init__.py | 0 b2b/djoser/social/backends/__init__.py | 0 b2b/djoser/social/backends/facebook.py | 5 + b2b/djoser/social/serializers.py | 55 +++ b2b/djoser/social/token/__init__.py | 0 b2b/djoser/social/token/jwt.py | 11 + b2b/djoser/social/urls.py | 10 + b2b/djoser/social/views.py | 26 ++ b2b/djoser/templates/email/activation.html | 28 ++ b2b/djoser/templates/email/confirmation.html | 21 ++ .../email/password_changed_confirmation.html | 21 ++ .../templates/email/password_reset.html | 29 ++ .../email/username_changed_confirmation.html | 21 ++ .../templates/email/username_reset.html | 29 ++ b2b/djoser/urls/__init__.py | 3 + b2b/djoser/urls/authtoken.py | 7 + b2b/djoser/urls/base.py | 10 + b2b/djoser/urls/jwt.py | 8 + b2b/djoser/utils.py | 37 ++ b2b/djoser/views.py | 307 ++++++++++++++++ b2b/djoser/webauthn/__init__.py | 0 b2b/djoser/webauthn/apps.py | 5 + .../webauthn/migrations/0001_initial.py | 44 +++ b2b/djoser/webauthn/migrations/__init__.py | 0 b2b/djoser/webauthn/models.py | 18 + b2b/djoser/webauthn/serializers.py | 65 ++++ b2b/djoser/webauthn/urls.py | 20 + b2b/djoser/webauthn/utils.py | 13 + b2b/djoser/webauthn/views.py | 171 +++++++++ 47 files changed, 2916 insertions(+) create mode 100644 b2b/djoser/.codacy.yml create mode 100644 b2b/djoser/__init__.py create mode 100644 b2b/djoser/compat.py create mode 100644 b2b/djoser/conf.py create mode 100644 b2b/djoser/constants.py create mode 100644 b2b/djoser/email.py create mode 100644 b2b/djoser/locale/de/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/es/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/fr/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/ja/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/ka/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/me/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/pl/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/pt_BR/LC_MESSAGES/django.po create mode 100644 b2b/djoser/locale/ru_RU/LC_MESSAGES/django.po create mode 100644 b2b/djoser/permissions.py create mode 100644 b2b/djoser/serializers.py create mode 100644 b2b/djoser/signals.py create mode 100644 b2b/djoser/social/__init__.py create mode 100644 b2b/djoser/social/backends/__init__.py create mode 100644 b2b/djoser/social/backends/facebook.py create mode 100644 b2b/djoser/social/serializers.py create mode 100644 b2b/djoser/social/token/__init__.py create mode 100644 b2b/djoser/social/token/jwt.py create mode 100644 b2b/djoser/social/urls.py create mode 100644 b2b/djoser/social/views.py create mode 100644 b2b/djoser/templates/email/activation.html create mode 100644 b2b/djoser/templates/email/confirmation.html create mode 100644 b2b/djoser/templates/email/password_changed_confirmation.html create mode 100644 b2b/djoser/templates/email/password_reset.html create mode 100644 b2b/djoser/templates/email/username_changed_confirmation.html create mode 100644 b2b/djoser/templates/email/username_reset.html create mode 100644 b2b/djoser/urls/__init__.py create mode 100644 b2b/djoser/urls/authtoken.py create mode 100644 b2b/djoser/urls/base.py create mode 100644 b2b/djoser/urls/jwt.py create mode 100644 b2b/djoser/utils.py create mode 100644 b2b/djoser/views.py create mode 100644 b2b/djoser/webauthn/__init__.py create mode 100644 b2b/djoser/webauthn/apps.py create mode 100644 b2b/djoser/webauthn/migrations/0001_initial.py create mode 100644 b2b/djoser/webauthn/migrations/__init__.py create mode 100644 b2b/djoser/webauthn/models.py create mode 100644 b2b/djoser/webauthn/serializers.py create mode 100644 b2b/djoser/webauthn/urls.py create mode 100644 b2b/djoser/webauthn/utils.py create mode 100644 b2b/djoser/webauthn/views.py diff --git a/b2b/djoser/.codacy.yml b/b2b/djoser/.codacy.yml new file mode 100644 index 0000000..686da54 --- /dev/null +++ b/b2b/djoser/.codacy.yml @@ -0,0 +1,3 @@ +exclude_paths: + - "docs/**" + - "testproject/**" diff --git a/b2b/djoser/__init__.py b/b2b/djoser/__init__.py new file mode 100644 index 0000000..8a124bf --- /dev/null +++ b/b2b/djoser/__init__.py @@ -0,0 +1 @@ +__version__ = "2.2.0" diff --git a/b2b/djoser/compat.py b/b2b/djoser/compat.py new file mode 100644 index 0000000..0c8f326 --- /dev/null +++ b/b2b/djoser/compat.py @@ -0,0 +1,12 @@ +from djoser.conf import settings + +__all__ = ["settings"] + + +def get_user_email(user): + email_field_name = get_user_email_field_name(user) + return getattr(user, email_field_name, None) + + +def get_user_email_field_name(user): + return user.get_email_field_name() diff --git a/b2b/djoser/conf.py b/b2b/djoser/conf.py new file mode 100644 index 0000000..dc85a03 --- /dev/null +++ b/b2b/djoser/conf.py @@ -0,0 +1,165 @@ +# flake8: noqa E501 +from django.apps import apps +from django.conf import settings as django_settings +from django.test.signals import setting_changed +from django.utils.functional import LazyObject +from django.utils.module_loading import import_string + +DJOSER_SETTINGS_NAMESPACE = "DJOSER" + +auth_module, user_model = django_settings.AUTH_USER_MODEL.rsplit(".", 1) + +User = apps.get_model(auth_module, user_model) + + +class ObjDict(dict): + def __getattribute__(self, item): + try: + val = self[item] + if isinstance(val, str): + val = import_string(val) + elif isinstance(val, (list, tuple)): + val = [import_string(v) if isinstance(v, str) else v for v in val] + self[item] = val + except KeyError: + val = super().__getattribute__(item) + + return val + + +default_settings = { + "USER_ID_FIELD": User._meta.pk.name, + "LOGIN_FIELD": User.USERNAME_FIELD, + "SEND_ACTIVATION_EMAIL": False, + "SEND_CONFIRMATION_EMAIL": False, + "USER_CREATE_PASSWORD_RETYPE": False, + "SET_PASSWORD_RETYPE": False, + "PASSWORD_RESET_CONFIRM_RETYPE": False, + "SET_USERNAME_RETYPE": False, + "USERNAME_RESET_CONFIRM_RETYPE": False, + "PASSWORD_RESET_SHOW_EMAIL_NOT_FOUND": False, + "USERNAME_RESET_SHOW_EMAIL_NOT_FOUND": False, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": False, + "USERNAME_CHANGED_EMAIL_CONFIRMATION": False, + "TOKEN_MODEL": "rest_framework.authtoken.models.Token", + "SERIALIZERS": ObjDict( + { + "activation": "djoser.serializers.ActivationSerializer", + "password_reset": "djoser.serializers.SendEmailResetSerializer", + "password_reset_confirm": "djoser.serializers.PasswordResetConfirmSerializer", + "password_reset_confirm_retype": "djoser.serializers.PasswordResetConfirmRetypeSerializer", + "set_password": "djoser.serializers.SetPasswordSerializer", + "set_password_retype": "djoser.serializers.SetPasswordRetypeSerializer", + "set_username": "djoser.serializers.SetUsernameSerializer", + "set_username_retype": "djoser.serializers.SetUsernameRetypeSerializer", + "username_reset": "djoser.serializers.SendEmailResetSerializer", + "username_reset_confirm": "djoser.serializers.UsernameResetConfirmSerializer", + "username_reset_confirm_retype": "djoser.serializers.UsernameResetConfirmRetypeSerializer", + "user_create": "djoser.serializers.UserCreateSerializer", + "user_create_password_retype": "djoser.serializers.UserCreatePasswordRetypeSerializer", + "user_delete": "djoser.serializers.UserDeleteSerializer", + "user": "djoser.serializers.UserSerializer", + "current_user": "djoser.serializers.UserSerializer", + "token": "djoser.serializers.TokenSerializer", + "token_create": "djoser.serializers.TokenCreateSerializer", + } + ), + "EMAIL": ObjDict( + { + "activation": "djoser.email.ActivationEmail", + "confirmation": "djoser.email.ConfirmationEmail", + "password_reset": "djoser.email.PasswordResetEmail", + "password_changed_confirmation": "djoser.email.PasswordChangedConfirmationEmail", + "username_changed_confirmation": "djoser.email.UsernameChangedConfirmationEmail", + "username_reset": "djoser.email.UsernameResetEmail", + } + ), + "CONSTANTS": ObjDict({"messages": "djoser.constants.Messages"}), + "LOGOUT_ON_PASSWORD_CHANGE": False, + "CREATE_SESSION_ON_LOGIN": False, + "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", + "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [], + "HIDE_USERS": True, + "PERMISSIONS": ObjDict( + { + "activation": ["rest_framework.permissions.AllowAny"], + "password_reset": ["rest_framework.permissions.AllowAny"], + "password_reset_confirm": ["rest_framework.permissions.AllowAny"], + "set_password": ["djoser.permissions.CurrentUserOrAdmin"], + "username_reset": ["rest_framework.permissions.AllowAny"], + "username_reset_confirm": ["rest_framework.permissions.AllowAny"], + "set_username": ["djoser.permissions.CurrentUserOrAdmin"], + "user_create": ["rest_framework.permissions.AllowAny"], + "user_delete": ["djoser.permissions.CurrentUserOrAdmin"], + "user": ["djoser.permissions.CurrentUserOrAdmin"], + "user_list": ["djoser.permissions.CurrentUserOrAdmin"], + "token_create": ["rest_framework.permissions.AllowAny"], + "token_destroy": ["rest_framework.permissions.IsAuthenticated"], + } + ), + "WEBAUTHN": ObjDict( + { + "RP_NAME": "localhost", + "RP_ID": "localhost", + "ORIGIN": "http://localhost:8000", + "CHALLENGE_LENGTH": 32, + "UKEY_LENGTH": 20, + "SIGNUP_SERIALIZER": "djoser.webauthn.serializers.WebauthnCreateUserSerializer", + "LOGIN_SERIALIZER": "djoser.webauthn.serializers.WebauthnLoginSerializer", + } + ), +} + +SETTINGS_TO_IMPORT = ["TOKEN_MODEL", "SOCIAL_AUTH_TOKEN_STRATEGY"] + + +class Settings: + def __init__(self, default_settings, explicit_overriden_settings: dict = None): + if explicit_overriden_settings is None: + explicit_overriden_settings = {} + + overriden_settings = ( + getattr(django_settings, DJOSER_SETTINGS_NAMESPACE, {}) + or explicit_overriden_settings + ) + + self._load_default_settings() + self._override_settings(overriden_settings) + self._init_settings_to_import() + + def _load_default_settings(self): + for setting_name, setting_value in default_settings.items(): + if setting_name.isupper(): + setattr(self, setting_name, setting_value) + + def _override_settings(self, overriden_settings: dict): + for setting_name, setting_value in overriden_settings.items(): + value = setting_value + if isinstance(setting_value, dict): + value = getattr(self, setting_name, {}) + value.update(ObjDict(setting_value)) + setattr(self, setting_name, value) + + def _init_settings_to_import(self): + for setting_name in SETTINGS_TO_IMPORT: + value = getattr(self, setting_name) + if isinstance(value, str): + setattr(self, setting_name, import_string(value)) + + +class LazySettings(LazyObject): + def _setup(self, explicit_overriden_settings=None): + self._wrapped = Settings(default_settings, explicit_overriden_settings) + + +settings = LazySettings() + + +def reload_djoser_settings(*args, **kwargs): + global settings + setting, value = kwargs["setting"], kwargs["value"] + if setting == DJOSER_SETTINGS_NAMESPACE: + settings._setup(explicit_overriden_settings=value) + + +setting_changed.connect(reload_djoser_settings) diff --git a/b2b/djoser/constants.py b/b2b/djoser/constants.py new file mode 100644 index 0000000..ef1209c --- /dev/null +++ b/b2b/djoser/constants.py @@ -0,0 +1,14 @@ +from django.utils.translation import gettext_lazy as _ + + +class Messages: + INVALID_CREDENTIALS_ERROR = _("Unable to log in with provided credentials.") + INACTIVE_ACCOUNT_ERROR = _("User account is disabled.") + INVALID_TOKEN_ERROR = _("Invalid token for given user.") + INVALID_UID_ERROR = _("Invalid user id or user doesn't exist.") + STALE_TOKEN_ERROR = _("Stale token for given user.") + PASSWORD_MISMATCH_ERROR = _("The two password fields didn't match.") + USERNAME_MISMATCH_ERROR = _("The two {0} fields didn't match.") + INVALID_PASSWORD_ERROR = _("Invalid password.") + EMAIL_NOT_FOUND = _("User with given email does not exist.") + CANNOT_CREATE_USER_ERROR = _("Unable to create account.") diff --git a/b2b/djoser/email.py b/b2b/djoser/email.py new file mode 100644 index 0000000..73f24e7 --- /dev/null +++ b/b2b/djoser/email.py @@ -0,0 +1,57 @@ +from django.contrib.auth.tokens import default_token_generator +from djoser import utils +from djoser.conf import settings +from templated_mail.mail import BaseEmailMessage + + +class ActivationEmail(BaseEmailMessage): + template_name = "email/activation.html" + + def get_context_data(self): + # ActivationEmail can be deleted + context = super().get_context_data() + + user = context.get("user") + context["uid"] = utils.encode_uid(user.pk) + context["token"] = default_token_generator.make_token(user) + context["url"] = settings.ACTIVATION_URL.format(**context) + return context + + +class ConfirmationEmail(BaseEmailMessage): + template_name = "email/confirmation.html" + + +class PasswordResetEmail(BaseEmailMessage): + template_name = "email/password_reset.html" + + def get_context_data(self): + # PasswordResetEmail can be deleted + context = super().get_context_data() + + user = context.get("user") + context["uid"] = utils.encode_uid(user.pk) + context["token"] = default_token_generator.make_token(user) + context["url"] = settings.PASSWORD_RESET_CONFIRM_URL.format(**context) + return context + + +class PasswordChangedConfirmationEmail(BaseEmailMessage): + template_name = "email/password_changed_confirmation.html" + + +class UsernameChangedConfirmationEmail(BaseEmailMessage): + template_name = "email/username_changed_confirmation.html" + + +class UsernameResetEmail(BaseEmailMessage): + template_name = "email/username_reset.html" + + def get_context_data(self): + context = super().get_context_data() + + user = context.get("user") + context["uid"] = utils.encode_uid(user.pk) + context["token"] = default_token_generator.make_token(user) + context["url"] = settings.USERNAME_RESET_CONFIRM_URL.format(**context) + return context diff --git a/b2b/djoser/locale/de/LC_MESSAGES/django.po b/b2b/djoser/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..35a0195 --- /dev/null +++ b/b2b/djoser/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,138 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +# Translators: +# Bertram Bühner , 2020 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: djoser\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-01-13 17:40+0100\n" +"Last-Translator: Bertram Bühner \n" +"Language-Team: German\n" +"Language: de\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" + +#: constants.py:4 +msgid "Unable to log in with provided credentials." +msgstr "Eine Anmeldung ist mit den angegebenen Daten nicht möglich." + +#: constants.py:5 +msgid "User account is disabled." +msgstr "Dieses Benutzerkonto ist deaktiviert." + +#: constants.py:6 +msgid "Invalid token for given user." +msgstr "Das Token für diesen Benutzer ist ungültig." + +#: constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "Die User-ID ist ungültig oder dieser Benutzer existiert nicht." + +#: constants.py:8 +msgid "Stale token for given user." +msgstr "Das Token für diesen Benutzer ist abgelaufen." + +#: constants.py:9 +msgid "The two password fields didn't match." +msgstr "Die beiden Passwörter stimmen nicht überein." + +#: constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "Die beiden {0}-Felder stimmen nicht überein." + +#: constants.py:11 +msgid "Invalid password." +msgstr "Das Passwort ist ungültig." + +#: constants.py:12 +msgid "User with given email does not exist." +msgstr "Es existiert kein Benutzer mit dieser E-Mailadresse." + +#: constants.py:13 +msgid "Unable to create account." +msgstr "Das Benutzerkonto kann nicht angelegt werden." + +#: constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "" +"Das user-model enthält kein E-Mail-Feld. Weitere Details unter " +"http://djoser.readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME " +"." + +#: templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Aktivierung des Benutzerkontos auf %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Sie erhalten diese E-Mail, da Sie den Aktivierungsprozess auf %(site_name)s." +"abschließen müssen." + +#: templates/email/activation.html:10 templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "Bitte begeben Sie sich auf folgende Seite, um die Aktivierung abzuschließen:" + +#: templates/email/activation.html:13 templates/email/activation.html:25 +#: templates/email/confirmation.html:10 templates/email/confirmation.html:18 +#: templates/email/password_reset.html:14 +#: templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Vielen Dank für die Nutzung unserer Seite!" + +#: templates/email/activation.html:15 templates/email/activation.html:27 +#: templates/email/confirmation.html:12 templates/email/confirmation.html:20 +#: templates/email/password_reset.html:16 +#: templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "Das Team von %(site_name)s" + +#: templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "" +"%(site_name)s - Ihr Benutzerkonto wurde erfolgreich erstellt und aktiviert!" + +#: templates/email/confirmation.html:8 templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Ihr Benutzerkonto wurde erstellt und ist freigeschaltet!" + +#: templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Zurücksetzen des Passworts auf %(site_name)s" + +#: templates/email/password_reset.html:8 templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Sie erhalten diese E-Mail, da Sie das Zurücksetzen Ihres Passworts auf " +"%(site_name)s angefordert haben." + +#: templates/email/password_reset.html:10 +#: templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Bitte geben Sie auf der folgenden Seite ein neues Passwort ein:" + +#: templates/email/password_reset.html:12 +#: templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Ihr Benutzername - falls Sie ihn vergessen haben - lautet:" diff --git a/b2b/djoser/locale/es/LC_MESSAGES/django.po b/b2b/djoser/locale/es/LC_MESSAGES/django.po new file mode 100644 index 0000000..f966ba5 --- /dev/null +++ b/b2b/djoser/locale/es/LC_MESSAGES/django.po @@ -0,0 +1,189 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +# Translators: +# Ariel Torti , 2020 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: djoser\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-04-18 20:06-0300\n" +"Last-Translator: Ariel Torti \n" +"Language-Team: Spanish\n" +"Language: es\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" + +#: djoser/constants.py:5 +msgid "Unable to log in with provided credentials." +msgstr "No es posible iniciar sesion con las credenciales proveídas." + +#: djoser/constants.py:6 +msgid "User account is disabled." +msgstr "Esta cuenta de usuario esta deshabilitada." + +#: djoser/constants.py:7 +msgid "Invalid token for given user." +msgstr "El token del usuario no es valido." + +#: djoser/constants.py:8 +msgid "Invalid user id or user doesn't exist." +msgstr "Id de usuario invalida o usuario inexistente." + +#: djoser/constants.py:9 +msgid "Stale token for given user." +msgstr "El token del usuario ha expirado." + +#: djoser/constants.py:10 +msgid "The two password fields didn't match." +msgstr "El contenido de los dos campos de contraseña no coincide." + +#: djoser/constants.py:11 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "El contenido de los dos campos de {0} no coincide." + +#: djoser/constants.py:12 +msgid "Invalid password." +msgstr "Contraseña invalida." + +#: djoser/constants.py:13 +msgid "User with given email does not exist." +msgstr "No existe un usuario con el email dado." + +#: djoser/constants.py:14 +msgid "Unable to create account." +msgstr "No es posible crear la cuenta de usuario." + +#: djoser/templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Activacion de la cuenta %(site_name)s" + +#: djoser/templates/email/activation.html:8 +#: djoser/templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Usted ha recibido este email porque necesita finalizar el proceso de activacion en " +"%(site_name)s." + +#: djoser/templates/email/activation.html:10 +#: djoser/templates/email/activation.html:21 +msgid "Please go to the following page to activate account:" +msgstr "Por favor diríjase a la siguiente página para activar su cuenta:" + +#: djoser/templates/email/activation.html:13 +#: djoser/templates/email/activation.html:24 +#: djoser/templates/email/confirmation.html:10 +#: djoser/templates/email/confirmation.html:18 +#: djoser/templates/email/password_changed_confirmation.html:10 +#: djoser/templates/email/password_changed_confirmation.html:18 +#: djoser/templates/email/password_reset.html:14 +#: djoser/templates/email/password_reset.html:26 +#: djoser/templates/email/username_changed_confirmation.html:10 +#: djoser/templates/email/username_changed_confirmation.html:18 +#: djoser/templates/email/username_reset.html:14 +#: djoser/templates/email/username_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Gracias por usar nuestro sitio!" + +#: djoser/templates/email/activation.html:15 +#: djoser/templates/email/activation.html:26 +#: djoser/templates/email/confirmation.html:12 +#: djoser/templates/email/confirmation.html:20 +#: djoser/templates/email/password_changed_confirmation.html:12 +#: djoser/templates/email/password_changed_confirmation.html:20 +#: djoser/templates/email/password_reset.html:16 +#: djoser/templates/email/password_reset.html:28 +#: djoser/templates/email/username_changed_confirmation.html:12 +#: djoser/templates/email/username_changed_confirmation.html:20 +#: djoser/templates/email/username_reset.html:16 +#: djoser/templates/email/username_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "El equipo de %(site_name)s" + +#: djoser/templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - Su cuenta ha sido creada y activada con exito!" + +#: djoser/templates/email/confirmation.html:8 +#: djoser/templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Su cuenta fue creada y esta lista para usarse!" + +#: djoser/templates/email/password_changed_confirmation.html:4 +#, python-format +msgid "%(site_name)s - Your password has been successfully changed!" +msgstr "%(site_name)s - Su contraseña ha sido cambiada con exito!" + +#: djoser/templates/email/password_changed_confirmation.html:8 +#: djoser/templates/email/password_changed_confirmation.html:16 +msgid "Your password has been changed!" +msgstr "Su contraseña ha sido cambiada!" + +#: djoser/templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Reseteo de contraseña en %(site_name)s" + +#: djoser/templates/email/password_reset.html:8 +#: djoser/templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Usted ha recibido este email porque solicito un cambio de contraseña para su " +"cuenta en %(site_name)s" + +#: djoser/templates/email/password_reset.html:10 +#: djoser/templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Por favor diríjase a la siguiente página para seleccionar su nueva contraseña:" + +#: djoser/templates/email/password_reset.html:12 +#: djoser/templates/email/password_reset.html:24 +#: djoser/templates/email/username_reset.html:12 +#: djoser/templates/email/username_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Su usuario, en caso que lo haya olvidado:" + +#: djoser/templates/email/username_changed_confirmation.html:4 +#, python-format +msgid "%(site_name)s - Your username has been successfully changed!" +msgstr "%(site_name)s - Su usuario ha sido cambiado con exito!" + +#: djoser/templates/email/username_changed_confirmation.html:8 +#: djoser/templates/email/username_changed_confirmation.html:16 +msgid "Your username has been changed!" +msgstr "Su usuario ha sido cambiado!" + +#: djoser/templates/email/username_reset.html:4 +#, python-format +msgid "Username reset on %(site_name)s" +msgstr "Reseteo de usuario en %(site_name)s" + +#: djoser/templates/email/username_reset.html:8 +#: djoser/templates/email/username_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a username reset for your " +"user account at %(site_name)s." +msgstr "" +"Usted ha recibido este email porque solicito un cambio de usuario para su " +"cuenta en %(site_name)s" + +#: djoser/templates/email/username_reset.html:10 +#: djoser/templates/email/username_reset.html:22 +msgid "Please go to the following page and choose a new username:" +msgstr "Por favor diríjase a la siguiente página para seleccionar su nuevo usuario:" diff --git a/b2b/djoser/locale/fr/LC_MESSAGES/django.po b/b2b/djoser/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..8eb6db1 --- /dev/null +++ b/b2b/djoser/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,185 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +# Translators: +# Julie Rymer , 2019 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: djoser\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-04-21 09:07+0200\n" +"PO-Revision-Date: 2019-05-14 19:03+0200\n" +"Last-Translator: Julie Rymer \n" +"Language-Team: French\n" +"Language: fr\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" + +#: constants.py:5 +msgid "Unable to log in with provided credentials." +msgstr "Impossible de se connecter avec les identifiants fournis." + +#: constants.py:6 +msgid "User account is disabled." +msgstr "Ce compte utilisateur est désactivé." + +#: constants.py:7 +msgid "Invalid token for given user." +msgstr "Le jeton d'authentification est invalide pour cet utilisateur." + +#: constants.py:8 +msgid "Invalid user id or user doesn't exist." +msgstr "L'id de l'utilisateur est invalide ou cet utilisateur n'existe pas." + +#: constants.py:9 +msgid "Stale token for given user." +msgstr "Le jeton pour cet utilisateur est expiré." + +#: constants.py:10 +msgid "The two password fields didn't match." +msgstr "Le contenu des deux champs mot de passe est différent." + +#: constants.py:11 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "Le contenu des deux champs {0} est différent." + +#: constants.py:12 +msgid "Invalid password." +msgstr "Mot de passe invalide." + +#: constants.py:13 +msgid "User with given email does not exist." +msgstr "Cette adresse email ne correspond à aucun utilisateur enregistré." + +#: constants.py:14 +msgid "Unable to create account." +msgstr "Création de compte impossible." + +#: templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Activation du compte sur %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Vous recevez cet email car vous devez finir le processus d'activation de " +"votre compte sur %(site_name)s." + +#: templates/email/activation.html:10 templates/email/activation.html:21 +msgid "Please go to the following page to activate account:" +msgstr "Veuillez cliquer sur le lien suivant pour activer votre compte :" + +#: templates/email/activation.html:13 templates/email/activation.html:24 +#: templates/email/confirmation.html:10 templates/email/confirmation.html:18 +#: templates/email/password_changed_confirmation.html:10 +#: templates/email/password_changed_confirmation.html:18 +#: templates/email/password_reset.html:14 +#: templates/email/password_reset.html:26 +#: templates/email/username_changed_confirmation.html:10 +#: templates/email/username_changed_confirmation.html:18 +#: templates/email/username_reset.html:14 +#: templates/email/username_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Merci d'avoir utilisé notre site !" + +#: templates/email/activation.html:15 templates/email/activation.html:26 +#: templates/email/confirmation.html:12 templates/email/confirmation.html:20 +#: templates/email/password_changed_confirmation.html:12 +#: templates/email/password_changed_confirmation.html:20 +#: templates/email/password_reset.html:16 +#: templates/email/password_reset.html:28 +#: templates/email/username_changed_confirmation.html:12 +#: templates/email/username_changed_confirmation.html:20 +#: templates/email/username_reset.html:16 +#: templates/email/username_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "L'équipe %(site_name)s" + +#: templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - Votre compte a été créé et activé avec succès !" + +#: templates/email/confirmation.html:8 templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Votre compte a été créé et est prêt a être utilisé !" + +#: templates/email/password_changed_confirmation.html:4 +#, python-format +msgid "%(site_name)s - Your password has been successfully changed!" +msgstr "%(site_name)s - Votre mot de passe a été changé avec succès !" + +#: templates/email/password_changed_confirmation.html:8 +#: templates/email/password_changed_confirmation.html:16 +msgid "Your password has been changed!" +msgstr "Votre mot de passe a été changé !" + +#: templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Réinitialisation du mot de passe sur %(site_name)s" + +#: templates/email/password_reset.html:8 templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Vous recevez cet email car vous avez demandé la réinitialisation du mot de " +"passe de votre compte sur %(site_name)s." + +#: templates/email/password_reset.html:10 +#: templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "" +"Merci de cliquer sur le lien suivant pour choisir un nouveau mot de passe :" + +#: templates/email/password_reset.html:12 +#: templates/email/password_reset.html:24 +#: templates/email/username_reset.html:12 +#: templates/email/username_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "" +"Votre nom d'utilisateur, au cas où vous l'auriez oublié, est le suivant :" + +#: templates/email/username_changed_confirmation.html:4 +#, python-format +msgid "%(site_name)s - Your username has been successfully changed!" +msgstr "%(site_name)s - Votre nom d'utilisateur a été changé avec succès !" + +#: templates/email/username_changed_confirmation.html:8 +#: templates/email/username_changed_confirmation.html:16 +msgid "Your username has been changed!" +msgstr "Votre nom d'utilisateur a été changé !" + +#: templates/email/username_reset.html:4 +#, python-format +msgid "Username reset on %(site_name)s" +msgstr "Réinitialisation du nom d'utilisateur sur %(site_name)s" + +#: templates/email/username_reset.html:8 templates/email/username_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a username reset for your " +"user account at %(site_name)s." +msgstr "" +"Vous recevez cet email car vous avez demandé la réinitialisation du nom " +"d'utilisateur de votre compte sur %(site_name)s." + +#: templates/email/username_reset.html:10 +#: templates/email/username_reset.html:22 +msgid "Please go to the following page and choose a new username:" +msgstr "" +"Merci de cliquer sur le lien suivant pour choisir un nouveau nom " +"d'utilisateur :" diff --git a/b2b/djoser/locale/ja/LC_MESSAGES/django.po b/b2b/djoser/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 0000000..999d7c3 --- /dev/null +++ b/b2b/djoser/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,139 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +msgid "" +msgstr "" +"Project-Id-Version: djoser\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-12-31 13:52-0700\n" +"Last-Translator: Daiki Nakashita \n" +"Language-Team: Japanese\n" +"Language: ja\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" + +#: djoser/constants.py:4 +msgid "Unable to login with provided credentials." +msgstr "入力された情報でログインできませんでした。" + +#: djoser/constants.py:5 +msgid "User account is disabled." +msgstr "使用不可能なアカウントです。" + +#: djoser/constants.py:6 +msgid "Invalid token for given user." +msgstr "有効なトークンではありません。" + +#: djoser/constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "ユーザーIDまたはユーザー自体が存在しません。" + +#: djoser/constants.py:8 +msgid "Stale token for given user." +msgstr "有効期限切れのトークンです。" + +#: djoser/constants.py:9 +msgid "The two password fields didn't match." +msgstr "入力された2つのパスワードが一致しませんでした。" + +#: djoser/constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "入力された2つの {0} が一致しませんでした。" + +#: djoser/constants.py:11 +msgid "Invalid password." +msgstr "パスワードが間違っています。" + +#: djoser/constants.py:12 +msgid "User with given email does not exist." +msgstr "入力されたemailを使用しているユーザーは見つかりませんでした。" + +#: djoser/constants.py:13 +msgid "Unable to create account." +msgstr "アカウント作成失敗。" + +#: djoser/constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "" +"指定されたemailフィールドを持つユーザーモデルが存在しません。 " +"http://djoser.readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME " +"上記から詳細を確認してください。" + +#: djoser/templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "%(site_name)s のアカウントを有効化してください" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"アカウントの有効化を忘れずに行ってください。 " +"%(site_name)s." + +#: djoser/templates/email/activation.html:10 +#: djoser/templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "次に記載されているページでアカウントの有効化を行ってください:" + +#: djoser/templates/email/activation.html:13 +#: djoser/templates/email/activation.html:25 +#: djoser/templates/email/confirmation.html:10 +#: djoser/templates/email/confirmation.html:18 +#: djoser/templates/email/password_reset.html:14 +#: djoser/templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "ご利用ありがとうございます!" + +#: djoser/templates/email/activation.html:15 +#: djoser/templates/email/activation.html:27 +#: djoser/templates/email/confirmation.html:12 +#: djoser/templates/email/confirmation.html:20 +#: djoser/templates/email/password_reset.html:16 +#: djoser/templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "チーム %(site_name)s" + +#: djoser/templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - アカウントの有効化が完了しました!" + +#: djoser/templates/email/confirmation.html:8 +#: djoser/templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "アカウントの有効化が完了し使用可能となりました!" + +#: djoser/templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "%(site_name)s のパスワードリセット" + +#: djoser/templates/email/password_reset.html:8 +#: djoser/templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"%(site_name)s のパスワードをリセットするためのemailとなります。" + +#: djoser/templates/email/password_reset.html:10 +#: djoser/templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "次のページにて新規パスワードを登録してください:" + +#: djoser/templates/email/password_reset.html:12 +#: djoser/templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "念のためこちらがユーザーネームとなります:" diff --git a/b2b/djoser/locale/ka/LC_MESSAGES/django.po b/b2b/djoser/locale/ka/LC_MESSAGES/django.po new file mode 100644 index 0000000..a0e2058 --- /dev/null +++ b/b2b/djoser/locale/ka/LC_MESSAGES/django.po @@ -0,0 +1,129 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +msgid "" +msgstr "" +"Project-Id-Version: 1.4.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-25 09:44+0100\n" +"PO-Revision-Date: 2019-01-29 14:25+0100\n" +"Last-Translator: Szymon Pyżalski \n" +"Language-Team: http://sunscrapers.com\n" +"Language: ka_GE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: constants.py:4 +msgid "Unable to login with provided credentials." +msgstr "შესვლა ამ სახელით და პაროლით არ არის შესაძლებელი." + +#: constants.py:5 +msgid "User account is disabled." +msgstr "ანგარიში გამორთულია." + +#: constants.py:6 +msgid "Invalid token for given user." +msgstr "ნიშანი ტოკენი ამ მომხმარებელისთვის" + +#: constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "სახელი არასწორია ან მომხმარებელი არ არსებობს." + +#: constants.py:8 +msgid "Stale token for given user." +msgstr "ნიშანი მოძველებულია." + +#: constants.py:9 +msgid "The two password fields didn't match." +msgstr "პაროლის ორი ველი ერთმანეთს არ ემთხვევა." + +#: constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "ორი {0} ერთმანეთს არ ემთხვევა." + +#: constants.py:11 +msgid "Invalid password." +msgstr "არასწორი პაროლი." + +#: constants.py:12 +msgid "User with given email does not exist." +msgstr "მომხმარებელი ამ იმეილით არ არსებობს." + +#: constants.py:13 +msgid "Unable to create account." +msgstr "ანგარიშის შექმნა შეუძლებულია." + +#: constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "მომხმარებელის მოდელი არ შეიცავს მითითებულ იმეილის ველს. ნახე " +"http://djoser.readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME " +"მეტი დეტალებისთვის" + +#: templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "ანგარიშის აკტივაცია საიტზე %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "თქვენ იღებთ ამ იმეილს, რადგან საჭიროა დაამთავროთ აკტივაციი პროცესი საიტზე %(site_name)s." + +#: templates/email/activation.html:10 templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "მიჰყევით ამ ლინკს რათა გაააკტიუროთ ანგარიში:" + +#: templates/email/activation.html:13 templates/email/activation.html:25 +#: templates/email/confirmation.html:10 templates/email/confirmation.html:18 +#: templates/email/password_reset.html:14 +#: templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "მადლობა საითის გამოიყენებას" + +#: templates/email/activation.html:15 templates/email/activation.html:27 +#: templates/email/confirmation.html:12 templates/email/confirmation.html:20 +#: templates/email/password_reset.html:16 +#: templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "საიტის %(site_name)s გუნდი" + +#: templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - თქვენი ანგარიში წარმატებით შეიქმნილია და გააკტიურებულია." + +#: templates/email/confirmation.html:8 templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "თქვენი ანგარიში შეიქმნილია და მზად არის გამოყენებისთვის!" + +#: templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "პაროლის აღდგენა საიტზე %(site_name)s." + +#: templates/email/password_reset.html:8 templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "ამ ემაილს იღებთ, რანდან თქვნენ მოთხოვეთ პაროლის აღდგენა თქვენი ანგარიშისთვის " +"%(site_name)s საიტზე." + +#: templates/email/password_reset.html:10 +#: templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "მოჰყევით ამ ლინკს და მიუთითეთ ახალი პაროლი:" + +#: templates/email/password_reset.html:12 +#: templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "თქვენი მომხმარებლის სახელი, იმ შემთხვევისათვის თუ დაგავიწყდათ:" diff --git a/b2b/djoser/locale/me/LC_MESSAGES/django.po b/b2b/djoser/locale/me/LC_MESSAGES/django.po new file mode 100644 index 0000000..7839b1e --- /dev/null +++ b/b2b/djoser/locale/me/LC_MESSAGES/django.po @@ -0,0 +1,133 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +# Translators: +# Nikola Kadić , 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: djoser\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-01-13 17:40+0100\n" +"Last-Translator: Nikola Kadić \n" +"Language-Team: Serbian\n" +"Language: me\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" + +#: constants.py:4 +msgid "Unable to log in with provided credentials." +msgstr "" + +#: constants.py:5 +msgid "User account is disabled." +msgstr "" + +#: constants.py:6 +msgid "Invalid token for given user." +msgstr "" + +#: constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "" + +#: constants.py:8 +msgid "Stale token for given user." +msgstr "" + +#: constants.py:9 +msgid "The two password fields didn't match." +msgstr "" + +#: constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "" + +#: constants.py:11 +msgid "Invalid password." +msgstr "" + +#: constants.py:12 +msgid "User with given email does not exist." +msgstr "" + +#: constants.py:13 +msgid "Unable to create account." +msgstr "" + +#: constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "" +"" + +#: templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Aktivacija korisničkog naloga za %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Dobili ste ovaj email jer treba da dovršite proces aktivacije naloga na %(site_name)s." + +#: templates/email/activation.html:10 templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "Molimo vas otvorite sljedeću stranicu da biste aktivirali korisnički nalog:" + +#: templates/email/activation.html:13 templates/email/activation.html:25 +#: templates/email/confirmation.html:10 templates/email/confirmation.html:18 +#: templates/email/password_reset.html:14 +#: templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Hvala što koristite naš sajt!" + +#: templates/email/activation.html:15 templates/email/activation.html:27 +#: templates/email/confirmation.html:12 templates/email/confirmation.html:20 +#: templates/email/password_reset.html:16 +#: templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "Tim %(site_name)" + +#: templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "" +"%(site_name) - Vaš korisnički nalog je uspješno kreiran i aktiviran!" + +#: templates/email/confirmation.html:8 templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Vaš korisnički nalog je kreiran i spreman za korišćenje!" + +#: templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Izmjena lozinke za %(site_name)" + +#: templates/email/password_reset.html:8 templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Dobili ste ovaj mejl jer ste zahtijevali izmjenu lozinke za korisnički nalog za %(site_name)" + +#: templates/email/password_reset.html:10 +#: templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Molimo vas otvorite sljedeću stranicu i izaberite novu lozinku:" + +#: templates/email/password_reset.html:12 +#: templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Vaše korisničko ime, u slučaju da ste zaboravili:" diff --git a/b2b/djoser/locale/pl/LC_MESSAGES/django.po b/b2b/djoser/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1b9b9d --- /dev/null +++ b/b2b/djoser/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,139 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +msgid "" +msgstr "" +"Project-Id-Version: 1.4.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-28 15:21+0100\n" +"PO-Revision-Date: 2019-01-29 14:25+0100\n" +"Last-Translator: Szymon Pyżalski \n" +"Language-Team: http://sunscrapers.com\n" +"Language: pl_PL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n" +"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n" +"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" +#: djoser/constants.py:4 +msgid "Unable to login with provided credentials." +msgstr "Nie udało się zalogować przy pomocy tych danych." + +#: djoser/constants.py:5 +msgid "User account is disabled." +msgstr "Konto wyłączone." + +#: djoser/constants.py:6 +msgid "Invalid token for given user." +msgstr "Błędny token dla tego użytkownika." + +#: djoser/constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "Błędna nazwa użytkownika, lub użytkownik nie istnieje." + +#: djoser/constants.py:8 +msgid "Stale token for given user." +msgstr "Przestarzały token dla tego użytkownika." + +#: djoser/constants.py:9 +msgid "The two password fields didn't match." +msgstr "Pola hasła nie pasują." + +#: djoser/constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "Pola {0} nie pasują." + +#: djoser/constants.py:11 +msgid "Invalid password." +msgstr "Błedne hasło." + +#: djoser/constants.py:12 +msgid "User with given email does not exist." +msgstr "Użytkownik z tym adresem email nie istnieje." + +#: djoser/constants.py:13 +msgid "Unable to create account." +msgstr "Nie udało się stworzyć konta." + +#: djoser/constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "Model użytkownika nie zawiera wskazanego pola email. Zobacz " +"http://djoser.readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME " +"by zobaczyć więcej szczegółów" + +#: djoser/templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Aktywacja konta na stronie %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "Otrzymałeś tego emaila, ponieważ potrzebujesz dokończyć proces aktywacji na " +"stronie %(site_name)s." + +#: djoser/templates/email/activation.html:10 +#: djoser/templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "Proszę przejdź na poniższą stronę, by aktywować konto:" + +#: djoser/templates/email/activation.html:13 +#: djoser/templates/email/activation.html:25 +#: djoser/templates/email/confirmation.html:10 +#: djoser/templates/email/confirmation.html:18 +#: djoser/templates/email/password_reset.html:14 +#: djoser/templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Dziękujemy za korzystanie z naszej strony!" + +#: djoser/templates/email/activation.html:15 +#: djoser/templates/email/activation.html:27 +#: djoser/templates/email/confirmation.html:12 +#: djoser/templates/email/confirmation.html:20 +#: djoser/templates/email/password_reset.html:16 +#: djoser/templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "Zespół strony %(site_name)s" + +#: djoser/templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - Twoje konto zostało stworzone i aktywowane" + +#: djoser/templates/email/confirmation.html:8 +#: djoser/templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Twoje konto zostało utworzone i jest gotowe do użycia" + +#: djoser/templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Reset hasła na stronie %(site_name)s" + +#: djoser/templates/email/password_reset.html:8 +#: djoser/templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "Otrzymałeś tego emaila, ponieważ prosiłeś o reset hasła do Twojego " +"konta na stronie %(site_name)s." + +#: djoser/templates/email/password_reset.html:10 +#: djoser/templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Przejdź do poniższej strony i wybierz nowe hasło:" + +#: djoser/templates/email/password_reset.html:12 +#: djoser/templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Twoja nazwa użytkownika:" diff --git a/b2b/djoser/locale/pt_BR/LC_MESSAGES/django.po b/b2b/djoser/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 0000000..80d62eb --- /dev/null +++ b/b2b/djoser/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,140 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +msgid "" +msgstr "" +"Project-Id-Version: djoser commit 298825d on master\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-03-18 21:45-0300\n" +"Last-Translator: Matheus Gomes \n" +"Language-Team: Brasileiro\n" +"Language: pt_BR\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" + +#: djoser/constants.py:4 +msgid "Unable to login with provided credentials." +msgstr "Não foi possível fazer login com os dados inseridos." + +#: djoser/constants.py:5 +msgid "User account is disabled." +msgstr "A conta do usuário está desativada." + +#: djoser/constants.py:6 +msgid "Invalid token for given user." +msgstr "Token inválido para o usuário fornecido." + +#: djoser/constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "ID de usuário inválido ou inexistente." + +#: djoser/constants.py:8 +msgid "Stale token for given user." +msgstr "Token expirado para o usuário fornecido." + +#: djoser/constants.py:9 +msgid "The two password fields didn't match." +msgstr "Os campos de senha não estão iguais." + +#: djoser/constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "Os dois campos {0} não estão iguais." + +#: djoser/constants.py:11 +msgid "Invalid password." +msgstr "Senha inválida." + +#: djoser/constants.py:12 +msgid "User with given email does not exist." +msgstr "Não existe um usuário com o email fornecido." + +#: djoser/constants.py:13 +msgid "Unable to create account." +msgstr "Não foi possível criar a conta." + +#: djoser/constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more " +"details." +msgstr "" +"O usuário fornecido não possui campo de email definido. Por favor, confira " +"http://djoser.readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME " +"para mais detalhes." + +#: djoser/templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Ativação de conta em %(site_name)s" + +#: templates/email/activation.html:8 templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Você está recebendo este email porque você precisa terminar o processo em " +"%(site_name)s." + +#: djoser/templates/email/activation.html:10 +#: djoser/templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "Por favor, visite a seguinte página para ativar sua conta:" + +#: djoser/templates/email/activation.html:13 +#: djoser/templates/email/activation.html:25 +#: djoser/templates/email/confirmation.html:10 +#: djoser/templates/email/confirmation.html:18 +#: djoser/templates/email/password_reset.html:14 +#: djoser/templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Obrigado por usar nosso site!" + +#: djoser/templates/email/activation.html:15 +#: djoser/templates/email/activation.html:27 +#: djoser/templates/email/confirmation.html:12 +#: djoser/templates/email/confirmation.html:20 +#: djoser/templates/email/password_reset.html:16 +#: djoser/templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "Time %(site_name)s" + +#: djoser/templates/email/confirmation.html:4 +#, python-format +msgid "" +"%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - Sua conta foi criada e ativada com sucesso!" + +#: djoser/templates/email/confirmation.html:8 +#: djoser/templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Sua conta foi criada com sucesso e está pronta para uso!" + +#: djoser/templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Redefina sua senha em %(site_name)s" + +#: djoser/templates/email/password_reset.html:8 +#: djoser/templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Você está recebendo este email porque você solicitou a redefinição de senha " +"para sua conta em %(site_name)s." + +#: djoser/templates/email/password_reset.html:10 +#: djoser/templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Por favor, visite a seguinte página para definir uma senha nova:" + +#: djoser/templates/email/password_reset.html:12 +#: djoser/templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Caso tenha esquecido, seu usuário:" diff --git a/b2b/djoser/locale/ru_RU/LC_MESSAGES/django.po b/b2b/djoser/locale/ru_RU/LC_MESSAGES/django.po new file mode 100644 index 0000000..8c9b4be --- /dev/null +++ b/b2b/djoser/locale/ru_RU/LC_MESSAGES/django.po @@ -0,0 +1,141 @@ +# Copyright (C) Sunscrapers +# This file is distributed under the same license as the djoser package. +# +msgid "" +msgstr "" +"Project-Id-Version: 1.4.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-28 15:21+0100\n" +"PO-Revision-Date: 2019-05-14 12:05+0300\n" +"Last-Translator: Sergey Ozeranskiy \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n" +"%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 2.2.1\n" +"X-Poedit-Basepath: ../../../..\n" +"X-Poedit-SearchPath-0: .\n" + +#: djoser/constants.py:4 +msgid "Unable to login with provided credentials." +msgstr "Невозможно войти с предоставленными учетными данными." + +#: djoser/constants.py:5 +msgid "User account is disabled." +msgstr "Учетная запись пользователя не активна." + +#: djoser/constants.py:6 +msgid "Invalid token for given user." +msgstr "Неверный токен для данного пользователя." + +#: djoser/constants.py:7 +msgid "Invalid user id or user doesn't exist." +msgstr "Неверный идентификатор пользователя или пользователь не существует." + +#: djoser/constants.py:8 +msgid "Stale token for given user." +msgstr "Устаревший токен для данного пользователя." + +#: djoser/constants.py:9 +msgid "The two password fields didn't match." +msgstr "Два пароля не совпадают." + +#: djoser/constants.py:10 +#, python-brace-format +msgid "The two {0} fields didn't match." +msgstr "Два значения поля {0} не совпадают." + +#: djoser/constants.py:11 +msgid "Invalid password." +msgstr "Неправильный пароль." + +#: djoser/constants.py:12 +msgid "User with given email does not exist." +msgstr "Пользователь с данным адресом электронной почты не существует." + +#: djoser/constants.py:13 +msgid "Unable to create account." +msgstr "Невозможно создать учетную запись." + +#: djoser/constants.py:15 +msgid "" +"User model does not contain specified email field. Please see http://djoser." +"readthedocs.io/en/latest/settings.html#USER_EMAIL_FIELD_NAME for more details." +msgstr "" +"Модель пользователя не содержит указанное поле для электронной почты. Пожалуйста, " +"посмотрите http://djoser.readthedocs.io/en/latest/settings." +"html#USER_EMAIL_FIELD_NAME для получения дополнительной информации." + +#: djoser/templates/email/activation.html:4 +#, python-format +msgid "Account activation on %(site_name)s" +msgstr "Активация аккаунта на %(site_name)s" + +#: djoser/templates/email/activation.html:8 djoser/templates/email/activation.html:19 +#, python-format +msgid "" +"You're receiving this email because you need to finish activation process on " +"%(site_name)s." +msgstr "" +"Вы получили это письмо, потому что вам нужно завершить процесс активации учетной " +"записи на %(site_name)s." + +#: djoser/templates/email/activation.html:10 djoser/templates/email/activation.html:22 +msgid "Please go to the following page to activate account:" +msgstr "" +"Пожалуйста, перейдите на следующую страницу, чтобы активировать учетную запись:" + +#: djoser/templates/email/activation.html:13 djoser/templates/email/activation.html:25 +#: djoser/templates/email/confirmation.html:10 +#: djoser/templates/email/confirmation.html:18 +#: djoser/templates/email/password_reset.html:14 +#: djoser/templates/email/password_reset.html:26 +msgid "Thanks for using our site!" +msgstr "Спасибо за использование нашего сайта!" + +#: djoser/templates/email/activation.html:15 djoser/templates/email/activation.html:27 +#: djoser/templates/email/confirmation.html:12 +#: djoser/templates/email/confirmation.html:20 +#: djoser/templates/email/password_reset.html:16 +#: djoser/templates/email/password_reset.html:28 +#, python-format +msgid "The %(site_name)s team" +msgstr "Команда %(site_name)s" + +#: djoser/templates/email/confirmation.html:4 +#, python-format +msgid "%(site_name)s - Your account has been successfully created and activated!" +msgstr "%(site_name)s - Ваша учетная запись была успешно создана и активирована!" + +#: djoser/templates/email/confirmation.html:8 +#: djoser/templates/email/confirmation.html:16 +msgid "Your account has been created and is ready to use!" +msgstr "Ваша учетная запись была создана и готова к использованию!" + +#: djoser/templates/email/password_reset.html:4 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Сброс пароля на %(site_name)s" + +#: djoser/templates/email/password_reset.html:8 +#: djoser/templates/email/password_reset.html:20 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your user " +"account at %(site_name)s." +msgstr "" +"Вы получили это письмо, потому что вы или кто-то другой запросили сброс пароля для " +"учетной записи пользователя на %(site_name)s." + +#: djoser/templates/email/password_reset.html:10 +#: djoser/templates/email/password_reset.html:22 +msgid "Please go to the following page and choose a new password:" +msgstr "Пожалуйста, перейдите на следующую страницу и создайте новый пароль:" + +#: djoser/templates/email/password_reset.html:12 +#: djoser/templates/email/password_reset.html:24 +msgid "Your username, in case you've forgotten:" +msgstr "Ваше имя пользователя, если вы забыли:" diff --git a/b2b/djoser/permissions.py b/b2b/djoser/permissions.py new file mode 100644 index 0000000..a7ac044 --- /dev/null +++ b/b2b/djoser/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions +from rest_framework.permissions import SAFE_METHODS + + +class CurrentUserOrAdmin(permissions.IsAuthenticated): + def has_object_permission(self, request, view, obj): + user = request.user + return user.is_staff or obj.pk == user.pk + + +class CurrentUserOrAdminOrReadOnly(permissions.IsAuthenticated): + def has_object_permission(self, request, view, obj): + user = request.user + if type(obj) == type(user) and obj == user: + return True + return request.method in SAFE_METHODS or user.is_staff diff --git a/b2b/djoser/serializers.py b/b2b/djoser/serializers.py new file mode 100644 index 0000000..991268d --- /dev/null +++ b/b2b/djoser/serializers.py @@ -0,0 +1,341 @@ +from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core import exceptions as django_exceptions +from django.db import IntegrityError, transaction +from djoser import utils +from djoser.compat import get_user_email, get_user_email_field_name +from djoser.conf import settings +from rest_framework import exceptions, serializers +from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = tuple(User.REQUIRED_FIELDS) + ( + settings.USER_ID_FIELD, + settings.LOGIN_FIELD, + ) + read_only_fields = (settings.LOGIN_FIELD,) + + def update(self, instance, validated_data): + email_field = get_user_email_field_name(User) + instance.email_changed = False + if settings.SEND_ACTIVATION_EMAIL and email_field in validated_data: + instance_email = get_user_email(instance) + if instance_email != validated_data[email_field]: + instance.is_active = False + instance.email_changed = True + instance.save(update_fields=["is_active"]) + return super().update(instance, validated_data) + + +class UserCreateMixin: + def create(self, validated_data): + try: + user = self.perform_create(validated_data) + except IntegrityError: + self.fail("cannot_create_user") + + return user + + def perform_create(self, validated_data): + with transaction.atomic(): + user = User.objects.create_user(**validated_data) + if settings.SEND_ACTIVATION_EMAIL: + user.is_active = False + user.save(update_fields=["is_active"]) + return user + + +class UserCreateSerializer(UserCreateMixin, serializers.ModelSerializer): + password = serializers.CharField(style={"input_type": "password"}, write_only=True) + + default_error_messages = { + "cannot_create_user": settings.CONSTANTS.messages.CANNOT_CREATE_USER_ERROR + } + + class Meta: + model = User + fields = tuple(User.REQUIRED_FIELDS) + ( + settings.LOGIN_FIELD, + settings.USER_ID_FIELD, + "password", + ) + + def validate(self, attrs): + user = User(**attrs) + password = attrs.get("password") + + try: + validate_password(password, user) + except django_exceptions.ValidationError as e: + serializer_error = serializers.as_serializer_error(e) + raise serializers.ValidationError( + {"password": serializer_error[api_settings.NON_FIELD_ERRORS_KEY]} + ) + + return attrs + + +class UserCreatePasswordRetypeSerializer(UserCreateSerializer): + default_error_messages = { + "password_mismatch": settings.CONSTANTS.messages.PASSWORD_MISMATCH_ERROR + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["re_password"] = serializers.CharField( + style={"input_type": "password"} + ) + + def validate(self, attrs): + self.fields.pop("re_password", None) + re_password = attrs.pop("re_password") + attrs = super().validate(attrs) + if attrs["password"] == re_password: + return attrs + else: + self.fail("password_mismatch") + + +class TokenCreateSerializer(serializers.Serializer): + password = serializers.CharField(required=False, style={"input_type": "password"}) + + default_error_messages = { + "invalid_credentials": settings.CONSTANTS.messages.INVALID_CREDENTIALS_ERROR, + "inactive_account": settings.CONSTANTS.messages.INACTIVE_ACCOUNT_ERROR, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = None + self.fields[settings.LOGIN_FIELD] = serializers.CharField(required=False) + + def validate(self, attrs): + password = attrs.get("password") + params = {settings.LOGIN_FIELD: attrs.get(settings.LOGIN_FIELD)} + self.user = authenticate( + request=self.context.get("request"), **params, password=password + ) + if not self.user: + self.user = User.objects.filter(**params).first() + if self.user and not self.user.check_password(password): + self.fail("invalid_credentials") + if self.user and self.user.is_active: + return attrs + self.fail("invalid_credentials") + + +class UserFunctionsMixin: + def get_user(self, is_active=True): + try: + user = User._default_manager.get( + is_active=is_active, + **{self.email_field: self.data.get(self.email_field, "")}, + ) + if user.has_usable_password(): + return user + except User.DoesNotExist: + pass + if ( + settings.PASSWORD_RESET_SHOW_EMAIL_NOT_FOUND + or settings.USERNAME_RESET_SHOW_EMAIL_NOT_FOUND + ): + self.fail("email_not_found") + + +class SendEmailResetSerializer(serializers.Serializer, UserFunctionsMixin): + default_error_messages = { + "email_not_found": settings.CONSTANTS.messages.EMAIL_NOT_FOUND + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.email_field = get_user_email_field_name(User) + self.fields[self.email_field] = serializers.EmailField() + + +class UidAndTokenSerializer(serializers.Serializer): + uid = serializers.CharField() + token = serializers.CharField() + + default_error_messages = { + "invalid_token": settings.CONSTANTS.messages.INVALID_TOKEN_ERROR, + "invalid_uid": settings.CONSTANTS.messages.INVALID_UID_ERROR, + } + + def validate(self, attrs): + validated_data = super().validate(attrs) + + # uid validation have to be here, because validate_ + # doesn't work with modelserializer + try: + uid = utils.decode_uid(self.initial_data.get("uid", "")) + self.user = User.objects.get(pk=uid) + except (User.DoesNotExist, ValueError, TypeError, OverflowError): + key_error = "invalid_uid" + raise ValidationError( + {"uid": [self.error_messages[key_error]]}, code=key_error + ) + + is_token_valid = self.context["view"].token_generator.check_token( + self.user, self.initial_data.get("token", "") + ) + if is_token_valid: + return validated_data + else: + key_error = "invalid_token" + raise ValidationError( + {"token": [self.error_messages[key_error]]}, code=key_error + ) + + +class ActivationSerializer(UidAndTokenSerializer): + default_error_messages = { + "stale_token": settings.CONSTANTS.messages.STALE_TOKEN_ERROR + } + + def validate(self, attrs): + attrs = super().validate(attrs) + if not self.user.is_active: + return attrs + raise exceptions.PermissionDenied(self.error_messages["stale_token"]) + + +class PasswordSerializer(serializers.Serializer): + new_password = serializers.CharField(style={"input_type": "password"}) + + def validate(self, attrs): + user = getattr(self, "user", None) or self.context["request"].user + # why assert? There are ValidationError / fail everywhere + assert user is not None + + try: + validate_password(attrs["new_password"], user) + except django_exceptions.ValidationError as e: + raise serializers.ValidationError({"new_password": list(e.messages)}) + return super().validate(attrs) + + +class PasswordRetypeSerializer(PasswordSerializer): + re_new_password = serializers.CharField(style={"input_type": "password"}) + + default_error_messages = { + "password_mismatch": settings.CONSTANTS.messages.PASSWORD_MISMATCH_ERROR + } + + def validate(self, attrs): + attrs = super().validate(attrs) + if attrs["new_password"] == attrs["re_new_password"]: + return attrs + else: + self.fail("password_mismatch") + + +class CurrentPasswordSerializer(serializers.Serializer): + current_password = serializers.CharField(style={"input_type": "password"}) + + default_error_messages = { + "invalid_password": settings.CONSTANTS.messages.INVALID_PASSWORD_ERROR + } + + def validate_current_password(self, value): + is_password_valid = self.context["request"].user.check_password(value) + if is_password_valid: + return value + else: + self.fail("invalid_password") + + +class UsernameSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = (settings.LOGIN_FIELD,) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.username_field = settings.LOGIN_FIELD + self._default_username_field = User.USERNAME_FIELD + self.fields[f"new_{self.username_field}"] = self.fields.pop(self.username_field) + + def save(self, **kwargs): + if self.username_field != self._default_username_field: + kwargs[User.USERNAME_FIELD] = self.validated_data.get( + f"new_{self.username_field}" + ) + return super().save(**kwargs) + + +class UsernameRetypeSerializer(UsernameSerializer): + default_error_messages = { + "username_mismatch": settings.CONSTANTS.messages.USERNAME_MISMATCH_ERROR.format( + settings.LOGIN_FIELD + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["re_new_" + settings.LOGIN_FIELD] = serializers.CharField() + + def validate(self, attrs): + attrs = super().validate(attrs) + new_username = attrs[settings.LOGIN_FIELD] + if new_username != attrs[f"re_new_{settings.LOGIN_FIELD}"]: + self.fail("username_mismatch") + else: + return attrs + + +class TokenSerializer(serializers.ModelSerializer): + auth_token = serializers.CharField(source="key") + + class Meta: + model = settings.TOKEN_MODEL + fields = ("auth_token",) + + +class SetPasswordSerializer(PasswordSerializer, CurrentPasswordSerializer): + pass + + +class SetPasswordRetypeSerializer(PasswordRetypeSerializer, CurrentPasswordSerializer): + pass + + +class PasswordResetConfirmSerializer(UidAndTokenSerializer, PasswordSerializer): + pass + + +class PasswordResetConfirmRetypeSerializer( + UidAndTokenSerializer, PasswordRetypeSerializer +): + pass + + +class UsernameResetConfirmSerializer(UidAndTokenSerializer, UsernameSerializer): + pass + + +class UsernameResetConfirmRetypeSerializer( + UidAndTokenSerializer, UsernameRetypeSerializer +): + pass + + +class UserDeleteSerializer(CurrentPasswordSerializer): + pass + + +class SetUsernameSerializer(UsernameSerializer, CurrentPasswordSerializer): + class Meta: + model = User + fields = (settings.LOGIN_FIELD, "current_password") + + +class SetUsernameRetypeSerializer(SetUsernameSerializer, UsernameRetypeSerializer): + pass diff --git a/b2b/djoser/signals.py b/b2b/djoser/signals.py new file mode 100644 index 0000000..5c0c67b --- /dev/null +++ b/b2b/djoser/signals.py @@ -0,0 +1,10 @@ +from django.dispatch import Signal + +# New user has registered. Args: user, request. +user_registered = Signal() + +# User has activated his or her account. Args: user, request. +user_activated = Signal() + +# User has been updated. Args: user, request. +user_updated = Signal() diff --git a/b2b/djoser/social/__init__.py b/b2b/djoser/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/djoser/social/backends/__init__.py b/b2b/djoser/social/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/djoser/social/backends/facebook.py b/b2b/djoser/social/backends/facebook.py new file mode 100644 index 0000000..e00b205 --- /dev/null +++ b/b2b/djoser/social/backends/facebook.py @@ -0,0 +1,5 @@ +from social_core.backends.facebook import FacebookOAuth2 + + +class FacebookOAuth2Override(FacebookOAuth2): + REDIRECT_STATE = False diff --git a/b2b/djoser/social/serializers.py b/b2b/djoser/social/serializers.py new file mode 100644 index 0000000..5c36025 --- /dev/null +++ b/b2b/djoser/social/serializers.py @@ -0,0 +1,55 @@ +from djoser.conf import settings +from rest_framework import serializers +from social_core import exceptions +from social_django.utils import load_backend, load_strategy + + +class ProviderAuthSerializer(serializers.Serializer): + # GET auth token + access = serializers.CharField(read_only=True) + refresh = serializers.CharField(read_only=True) + user = serializers.CharField(read_only=True) + + def create(self, validated_data): + user = validated_data["user"] + return settings.SOCIAL_AUTH_TOKEN_STRATEGY.obtain(user) + + def validate(self, attrs): + request = self.context["request"] + if "state" in request.GET: + self._validate_state(request.GET["state"]) + + strategy = load_strategy(request) + redirect_uri = strategy.session_get("redirect_uri") + + backend_name = self.context["view"].kwargs["provider"] + backend = load_backend(strategy, backend_name, redirect_uri=redirect_uri) + + try: + user = backend.auth_complete() + except exceptions.AuthException as e: + raise serializers.ValidationError(str(e)) + return {"user": user} + + def _validate_state(self, value): + request = self.context["request"] + strategy = load_strategy(request) + redirect_uri = strategy.session_get("redirect_uri") + + backend_name = self.context["view"].kwargs["provider"] + backend = load_backend(strategy, backend_name, redirect_uri=redirect_uri) + + try: + backend.validate_state() + except exceptions.AuthMissingParameter: + raise serializers.ValidationError( + "State could not be found in request data." + ) + except exceptions.AuthStateMissing: + raise serializers.ValidationError( + "State could not be found in server-side session data." + ) + except exceptions.AuthStateForbidden: + raise serializers.ValidationError("Invalid state has been provided.") + + return value diff --git a/b2b/djoser/social/token/__init__.py b/b2b/djoser/social/token/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/djoser/social/token/jwt.py b/b2b/djoser/social/token/jwt.py new file mode 100644 index 0000000..7f7f880 --- /dev/null +++ b/b2b/djoser/social/token/jwt.py @@ -0,0 +1,11 @@ +class TokenStrategy: + @classmethod + def obtain(cls, user): + from rest_framework_simplejwt.tokens import RefreshToken + + refresh = RefreshToken.for_user(user) + return { + "access": str(refresh.access_token), + "refresh": str(refresh), + "user": user, + } diff --git a/b2b/djoser/social/urls.py b/b2b/djoser/social/urls.py new file mode 100644 index 0000000..3c9196e --- /dev/null +++ b/b2b/djoser/social/urls.py @@ -0,0 +1,10 @@ +from django.urls import re_path +from djoser.social import views + +urlpatterns = [ + re_path( + r"^o/(?P\S+)/$", + views.ProviderAuthView.as_view(), + name="provider-auth", + ) +] diff --git a/b2b/djoser/social/views.py b/b2b/djoser/social/views.py new file mode 100644 index 0000000..047445e --- /dev/null +++ b/b2b/djoser/social/views.py @@ -0,0 +1,26 @@ +from djoser.conf import settings +from djoser.social.serializers import ProviderAuthSerializer +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from social_django.utils import load_backend, load_strategy + + +class ProviderAuthView(generics.CreateAPIView): + permission_classes = [permissions.AllowAny] + serializer_class = ProviderAuthSerializer + + def get(self, request, *args, **kwargs): + redirect_uri = request.GET.get("redirect_uri") + if redirect_uri not in settings.SOCIAL_AUTH_ALLOWED_REDIRECT_URIS: + return Response( + "redirect_uri must be in SOCIAL_AUTH_ALLOWED_REDIRECT_URIS", + status=status.HTTP_400_BAD_REQUEST, + ) + strategy = load_strategy(request) + strategy.session_set("redirect_uri", redirect_uri) + + backend_name = self.kwargs["provider"] + backend = load_backend(strategy, backend_name, redirect_uri=redirect_uri) + + authorization_url = backend.auth_url() + return Response(data={"authorization_url": authorization_url}) diff --git a/b2b/djoser/templates/email/activation.html b/b2b/djoser/templates/email/activation.html new file mode 100644 index 0000000..f1a8536 --- /dev/null +++ b/b2b/djoser/templates/email/activation.html @@ -0,0 +1,28 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Account activation on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you need to finish an activation process on {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page to activate account:" %} +{{ protocol }}://{{ domain }}/{{ url|safe }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you need to finish an activation process on {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page to activate account:" %}

+

{{ protocol }}://{{ domain }}/{{ url|safe }}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+ +{% endblock html_body %} diff --git a/b2b/djoser/templates/email/confirmation.html b/b2b/djoser/templates/email/confirmation.html new file mode 100644 index 0000000..1d26efc --- /dev/null +++ b/b2b/djoser/templates/email/confirmation.html @@ -0,0 +1,21 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}{{ site_name }} - Your account has been successfully created and activated!{% endblocktrans %} +{% endblock %} + +{% block text_body %} +{% trans "Your account has been created and is ready to use!" %} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% trans "Your account has been created and is ready to use!" %}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/b2b/djoser/templates/email/password_changed_confirmation.html b/b2b/djoser/templates/email/password_changed_confirmation.html new file mode 100644 index 0000000..fdf6658 --- /dev/null +++ b/b2b/djoser/templates/email/password_changed_confirmation.html @@ -0,0 +1,21 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}{{ site_name }} - Your password has been successfully changed!{% endblocktrans %} +{% endblock %} + +{% block text_body %} +{% trans "Your password has been changed!" %} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% trans "Your password has been changed!" %}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/b2b/djoser/templates/email/password_reset.html b/b2b/djoser/templates/email/password_reset.html new file mode 100644 index 0000000..19244aa --- /dev/null +++ b/b2b/djoser/templates/email/password_reset.html @@ -0,0 +1,29 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Password reset on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} +{{ protocol }}://{{ domain }}/{{ url|safe }} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page and choose a new password:" %}

+{{ protocol }}://{{ domain }}/{{ url|safe }} +

{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/b2b/djoser/templates/email/username_changed_confirmation.html b/b2b/djoser/templates/email/username_changed_confirmation.html new file mode 100644 index 0000000..053ac66 --- /dev/null +++ b/b2b/djoser/templates/email/username_changed_confirmation.html @@ -0,0 +1,21 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}{{ site_name }} - Your username has been successfully changed!{% endblocktrans %} +{% endblock %} + +{% block text_body %} +{% trans "Your username has been changed!" %} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% trans "Your username has been changed!" %}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/b2b/djoser/templates/email/username_reset.html b/b2b/djoser/templates/email/username_reset.html new file mode 100644 index 0000000..0d291df --- /dev/null +++ b/b2b/djoser/templates/email/username_reset.html @@ -0,0 +1,29 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Username reset on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you requested a username reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new username:" %} +{{ protocol }}://{{ domain }}/{{ url|safe }} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you requested a username reset for your user account at {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page and choose a new username:" %}

+{{ protocol }}://{{ domain }}/{{ url|safe }} +

{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}

+ +

{% trans "Thanks for using our site!" %}

+ +

{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

+{% endblock html_body %} diff --git a/b2b/djoser/urls/__init__.py b/b2b/djoser/urls/__init__.py new file mode 100644 index 0000000..d2ad6b9 --- /dev/null +++ b/b2b/djoser/urls/__init__.py @@ -0,0 +1,3 @@ +from .base import urlpatterns + +__all__ = ["urlpatterns"] diff --git a/b2b/djoser/urls/authtoken.py b/b2b/djoser/urls/authtoken.py new file mode 100644 index 0000000..48b006e --- /dev/null +++ b/b2b/djoser/urls/authtoken.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from djoser import views + +urlpatterns = [ + re_path(r"^token/login/?$", views.TokenCreateView.as_view(), name="login"), + re_path(r"^token/logout/?$", views.TokenDestroyView.as_view(), name="logout"), +] diff --git a/b2b/djoser/urls/base.py b/b2b/djoser/urls/base.py new file mode 100644 index 0000000..91730a6 --- /dev/null +++ b/b2b/djoser/urls/base.py @@ -0,0 +1,10 @@ +from django.contrib.auth import get_user_model +from djoser import views +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register("users", views.UserViewSet) + +User = get_user_model() + +urlpatterns = router.urls diff --git a/b2b/djoser/urls/jwt.py b/b2b/djoser/urls/jwt.py new file mode 100644 index 0000000..474411f --- /dev/null +++ b/b2b/djoser/urls/jwt.py @@ -0,0 +1,8 @@ +from django.urls import re_path +from rest_framework_simplejwt import views + +urlpatterns = [ + re_path(r"^jwt/create/?", views.TokenObtainPairView.as_view(), name="jwt-create"), + re_path(r"^jwt/refresh/?", views.TokenRefreshView.as_view(), name="jwt-refresh"), + re_path(r"^jwt/verify/?", views.TokenVerifyView.as_view(), name="jwt-verify"), +] diff --git a/b2b/djoser/utils.py b/b2b/djoser/utils.py new file mode 100644 index 0000000..98a0ffe --- /dev/null +++ b/b2b/djoser/utils.py @@ -0,0 +1,37 @@ +from django.contrib.auth import login, logout, user_logged_in, user_logged_out +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from djoser.conf import settings + + +def encode_uid(pk): + return force_str(urlsafe_base64_encode(force_bytes(pk))) + + +def decode_uid(pk): + return force_str(urlsafe_base64_decode(pk)) + + +def login_user(request, user): + token, _ = settings.TOKEN_MODEL.objects.get_or_create(user=user) + if settings.CREATE_SESSION_ON_LOGIN: + login(request, user) + user_logged_in.send(sender=user.__class__, request=request, user=user) + return token + + +def logout_user(request): + if settings.TOKEN_MODEL: + settings.TOKEN_MODEL.objects.filter(user=request.user).delete() + user_logged_out.send( + sender=request.user.__class__, request=request, user=request.user + ) + if settings.CREATE_SESSION_ON_LOGIN: + logout(request) + + +class ActionViewMixin: + def post(self, request, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._action(serializer) diff --git a/b2b/djoser/views.py b/b2b/djoser/views.py new file mode 100644 index 0000000..41ea559 --- /dev/null +++ b/b2b/djoser/views.py @@ -0,0 +1,307 @@ +from django.contrib.auth import get_user_model, update_session_auth_hash +from django.contrib.auth.tokens import default_token_generator +from django.utils.timezone import now +from djoser import signals, utils +from djoser.compat import get_user_email +from djoser.conf import settings +from rest_framework import generics, status, views, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +User = get_user_model() + + +class TokenCreateView(utils.ActionViewMixin, generics.GenericAPIView): + """ + Use this endpoint to obtain user authentication token. + """ + + serializer_class = settings.SERIALIZERS.token_create + permission_classes = settings.PERMISSIONS.token_create + + def _action(self, serializer): + token = utils.login_user(self.request, serializer.user) + token_serializer_class = settings.SERIALIZERS.token + return Response( + data=token_serializer_class(token).data, status=status.HTTP_200_OK + ) + + +class TokenDestroyView(views.APIView): + """ + Use this endpoint to logout user (remove user authentication token). + """ + + permission_classes = settings.PERMISSIONS.token_destroy + + def post(self, request): + utils.logout_user(request) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserViewSet(viewsets.ModelViewSet): + serializer_class = settings.SERIALIZERS.user + queryset = User.objects.all() + permission_classes = settings.PERMISSIONS.user + token_generator = default_token_generator + lookup_field = settings.USER_ID_FIELD + + def permission_denied(self, request, **kwargs): + if ( + settings.HIDE_USERS + and request.user.is_authenticated + and self.action in ["update", "partial_update", "list", "retrieve"] + ): + raise NotFound() + super().permission_denied(request, **kwargs) + + def get_queryset(self): + user = self.request.user + queryset = super().get_queryset() + if settings.HIDE_USERS and self.action == "list" and not user.is_staff: + queryset = queryset.filter(pk=user.pk) + return queryset + + def get_permissions(self): + if self.action == "create": + self.permission_classes = settings.PERMISSIONS.user_create + elif self.action == "activation": + self.permission_classes = settings.PERMISSIONS.activation + elif self.action == "resend_activation": + self.permission_classes = settings.PERMISSIONS.password_reset + elif self.action == "list": + self.permission_classes = settings.PERMISSIONS.user_list + elif self.action == "reset_password": + self.permission_classes = settings.PERMISSIONS.password_reset + elif self.action == "reset_password_confirm": + self.permission_classes = settings.PERMISSIONS.password_reset_confirm + elif self.action == "set_password": + self.permission_classes = settings.PERMISSIONS.set_password + elif self.action == "set_username": + self.permission_classes = settings.PERMISSIONS.set_username + elif self.action == "reset_username": + self.permission_classes = settings.PERMISSIONS.username_reset + elif self.action == "reset_username_confirm": + self.permission_classes = settings.PERMISSIONS.username_reset_confirm + elif self.action == "destroy" or ( + self.action == "me" and self.request and self.request.method == "DELETE" + ): + self.permission_classes = settings.PERMISSIONS.user_delete + return super().get_permissions() + + def get_serializer_class(self): + if self.action == "create": + if settings.USER_CREATE_PASSWORD_RETYPE: + return settings.SERIALIZERS.user_create_password_retype + return settings.SERIALIZERS.user_create + elif self.action == "destroy" or ( + self.action == "me" and self.request and self.request.method == "DELETE" + ): + return settings.SERIALIZERS.user_delete + elif self.action == "activation": + return settings.SERIALIZERS.activation + elif self.action == "resend_activation": + return settings.SERIALIZERS.password_reset + elif self.action == "reset_password": + return settings.SERIALIZERS.password_reset + elif self.action == "reset_password_confirm": + if settings.PASSWORD_RESET_CONFIRM_RETYPE: + return settings.SERIALIZERS.password_reset_confirm_retype + return settings.SERIALIZERS.password_reset_confirm + elif self.action == "set_password": + if settings.SET_PASSWORD_RETYPE: + return settings.SERIALIZERS.set_password_retype + return settings.SERIALIZERS.set_password + elif self.action == "set_username": + if settings.SET_USERNAME_RETYPE: + return settings.SERIALIZERS.set_username_retype + return settings.SERIALIZERS.set_username + elif self.action == "reset_username": + return settings.SERIALIZERS.username_reset + elif self.action == "reset_username_confirm": + if settings.USERNAME_RESET_CONFIRM_RETYPE: + return settings.SERIALIZERS.username_reset_confirm_retype + return settings.SERIALIZERS.username_reset_confirm + elif self.action == "me": + return settings.SERIALIZERS.current_user + + return self.serializer_class + + def get_instance(self): + return self.request.user + + def perform_create(self, serializer, *args, **kwargs): + user = serializer.save(*args, **kwargs) + signals.user_registered.send( + sender=self.__class__, user=user, request=self.request + ) + + context = {"user": user} + to = [get_user_email(user)] + if settings.SEND_ACTIVATION_EMAIL: + settings.EMAIL.activation(self.request, context).send(to) + elif settings.SEND_CONFIRMATION_EMAIL: + settings.EMAIL.confirmation(self.request, context).send(to) + + def perform_update(self, serializer, *args, **kwargs): + super().perform_update(serializer, *args, **kwargs) + user = serializer.instance + signals.user_updated.send( + sender=self.__class__, user=user, request=self.request + ) + + # should we send activation email after update? + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data) + serializer.is_valid(raise_exception=True) + + if instance == request.user: + utils.logout_user(self.request) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["get", "put", "patch", "delete"], detail=False) + def me(self, request, *args, **kwargs): + self.get_object = self.get_instance + if request.method == "GET": + return self.retrieve(request, *args, **kwargs) + elif request.method == "PUT": + return self.update(request, *args, **kwargs) + elif request.method == "PATCH": + return self.partial_update(request, *args, **kwargs) + elif request.method == "DELETE": + return self.destroy(request, *args, **kwargs) + + @action(["post"], detail=False) + def activation(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + user.is_active = True + user.save() + + signals.user_activated.send( + sender=self.__class__, user=user, request=self.request + ) + + if settings.SEND_CONFIRMATION_EMAIL: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.confirmation(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False) + def resend_activation(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user(is_active=False) + + if not settings.SEND_ACTIVATION_EMAIL or not user: + return Response(status=status.HTTP_400_BAD_REQUEST) + + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False) + def set_password(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + self.request.user.set_password(serializer.data["new_password"]) + self.request.user.save() + + if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: + context = {"user": self.request.user} + to = [get_user_email(self.request.user)] + settings.EMAIL.password_changed_confirmation(self.request, context).send(to) + + if settings.LOGOUT_ON_PASSWORD_CHANGE: + utils.logout_user(self.request) + elif settings.CREATE_SESSION_ON_LOGIN: + update_session_auth_hash(self.request, self.request.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False) + def reset_password(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user() + + if user: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.password_reset(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False) + def reset_password_confirm(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + serializer.user.set_password(serializer.data["new_password"]) + if hasattr(serializer.user, "last_login"): + serializer.user.last_login = now() + serializer.user.save() + + if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: + context = {"user": serializer.user} + to = [get_user_email(serializer.user)] + settings.EMAIL.password_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False, url_path=f"set_{User.USERNAME_FIELD}") + def set_username(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = self.request.user + new_username = serializer.data["new_" + User.USERNAME_FIELD] + + setattr(user, User.USERNAME_FIELD, new_username) + user.save() + if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.username_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False, url_path=f"reset_{User.USERNAME_FIELD}") + def reset_username(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user() + + if user: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.username_reset(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False, url_path=f"reset_{User.USERNAME_FIELD}_confirm") + def reset_username_confirm(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + new_username = serializer.data["new_" + User.USERNAME_FIELD] + + setattr(serializer.user, User.USERNAME_FIELD, new_username) + if hasattr(serializer.user, "last_login"): + serializer.user.last_login = now() + serializer.user.save() + + if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: + context = {"user": serializer.user} + to = [get_user_email(serializer.user)] + settings.EMAIL.username_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/b2b/djoser/webauthn/__init__.py b/b2b/djoser/webauthn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/djoser/webauthn/apps.py b/b2b/djoser/webauthn/apps.py new file mode 100644 index 0000000..a97d548 --- /dev/null +++ b/b2b/djoser/webauthn/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebauthnConfig(AppConfig): + name = "djoser.webauthn" diff --git a/b2b/djoser/webauthn/migrations/0001_initial.py b/b2b/djoser/webauthn/migrations/0001_initial.py new file mode 100644 index 0000000..132aa55 --- /dev/null +++ b/b2b/djoser/webauthn/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.4 on 2019-09-05 05:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name="CredentialOptions", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("challenge", models.TextField()), + ("username", models.TextField(unique=True)), + ("display_name", models.TextField()), + ("ukey", models.TextField(unique=True)), + ("credential_id", models.TextField()), + ("sign_count", models.IntegerField(null=True)), + ("public_key", models.TextField()), + ( + "user", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="credential_options", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ) + ] diff --git a/b2b/djoser/webauthn/migrations/__init__.py b/b2b/djoser/webauthn/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/djoser/webauthn/models.py b/b2b/djoser/webauthn/models.py new file mode 100644 index 0000000..6a79fff --- /dev/null +++ b/b2b/djoser/webauthn/models.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.db import models + + +class CredentialOptions(models.Model): + challenge = models.TextField() + username = models.TextField(unique=True) + display_name = models.TextField() + ukey = models.TextField(unique=True) + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="credential_options", + null=True, + on_delete=models.CASCADE, + ) + credential_id = models.TextField() + sign_count = models.IntegerField(null=True) + public_key = models.TextField() diff --git a/b2b/djoser/webauthn/serializers.py b/b2b/djoser/webauthn/serializers.py new file mode 100644 index 0000000..3765c5f --- /dev/null +++ b/b2b/djoser/webauthn/serializers.py @@ -0,0 +1,65 @@ +from django.contrib.auth import get_user_model +from djoser.conf import settings +from djoser.serializers import UserCreateMixin +from rest_framework import serializers + +from .models import CredentialOptions +from .utils import create_challenge, create_ukey + +User = get_user_model() + + +class WebauthnSignupSerializer(serializers.ModelSerializer): + class Meta: + model = CredentialOptions + fields = ("username", "display_name") + + def create(self, validated_data): + validated_data.update( + { + "challenge": create_challenge( + length=settings.WEBAUTHN["CHALLENGE_LENGTH"] + ), + "ukey": create_ukey(length=settings.WEBAUTHN["UKEY_LENGTH"]), + } + ) + return super().create(validated_data) + + def validate_username(self, username): + if User.objects.filter(username=username).exists(): + raise serializers.ValidationError(f"User {username} already exists.") + return username + + +class WebauthnCreateUserSerializer(UserCreateMixin, serializers.ModelSerializer): + class Meta: + model = User + fields = tuple(User.REQUIRED_FIELDS) + ( + settings.LOGIN_FIELD, + User._meta.pk.name, + ) + + +class WebauthnLoginSerializer(serializers.Serializer): + default_error_messages = { + "invalid_credentials": settings.CONSTANTS.messages.INVALID_CREDENTIALS_ERROR + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields[settings.LOGIN_FIELD] = serializers.CharField(required=True) + + def validate_username(self, username): + try: + search_kwargs = { + settings.LOGIN_FIELD: username, + "credential_options__isnull": False, + } + self.user = user = User.objects.get(**search_kwargs) + except User.DoesNotExist: + self.fail("invalid_credentials") + + if not user.is_active: + self.fail("invalid_credentials") + + return username diff --git a/b2b/djoser/webauthn/urls.py b/b2b/djoser/webauthn/urls.py new file mode 100644 index 0000000..0f9c409 --- /dev/null +++ b/b2b/djoser/webauthn/urls.py @@ -0,0 +1,20 @@ +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path( + r"^signup_request/$", + views.SingupRequestView.as_view(), + name="webauthn_signup_request", + ), + re_path( + r"^signup/(?P.+)/$", views.SignupView.as_view(), name="webauthn_signup" + ), + re_path( + r"^login_request/$", + views.LoginRequestView.as_view(), + name="webauthn_login_request", + ), + re_path(r"^login/$", views.LoginView.as_view(), name="webauthn_login"), +] diff --git a/b2b/djoser/webauthn/utils.py b/b2b/djoser/webauthn/utils.py new file mode 100644 index 0000000..faf9b69 --- /dev/null +++ b/b2b/djoser/webauthn/utils.py @@ -0,0 +1,13 @@ +import string +from random import SystemRandom + +random = SystemRandom() +challenge_characters = string.ascii_letters + string.digits + + +def create_challenge(length): + return "".join(random.choices(challenge_characters, k=length)) + + +def create_ukey(length): + return create_challenge(length) diff --git a/b2b/djoser/webauthn/views.py b/b2b/djoser/webauthn/views.py new file mode 100644 index 0000000..83a481e --- /dev/null +++ b/b2b/djoser/webauthn/views.py @@ -0,0 +1,171 @@ +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from djoser import signals +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.utils import login_user +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView +from webauthn import ( + WebAuthnAssertionOptions, + WebAuthnAssertionResponse, + WebAuthnMakeCredentialOptions, + WebAuthnRegistrationResponse, + WebAuthnUser, +) +from webauthn.webauthn import ( + AuthenticationRejectedException, + RegistrationRejectedException, +) + +from .models import CredentialOptions +from .serializers import WebauthnLoginSerializer, WebauthnSignupSerializer +from .utils import create_challenge + +User = get_user_model() + + +class SingupRequestView(APIView): + permission_classes = (AllowAny,) + + def post(self, request): + serializer = WebauthnSignupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + co = serializer.save() + + credential_registration_dict = WebAuthnMakeCredentialOptions( + challenge=co.challenge, + rp_name=settings.WEBAUTHN["RP_NAME"], + rp_id=settings.WEBAUTHN["RP_ID"], + user_id=co.ukey, + username=co.username, + display_name=co.display_name, + icon_url="", + ) + + return Response(credential_registration_dict.registration_dict) + + +class SignupView(APIView): + permission_classes = (AllowAny,) + serializer_class = settings.WEBAUTHN.SIGNUP_SERIALIZER + + def post(self, request, ukey): + co = get_object_or_404(CredentialOptions, ukey=ukey) + user_serializer = self.serializer_class(data=request.data) + user_serializer.is_valid(raise_exception=True) + + webauthn_registration_response = WebAuthnRegistrationResponse( + rp_id=settings.WEBAUTHN["RP_ID"], + origin=settings.WEBAUTHN["ORIGIN"], + registration_response=request.data, + challenge=co.challenge, + none_attestation_permitted=True, + ) + try: + webauthn_credential = webauthn_registration_response.verify() + except RegistrationRejectedException: + return Response( + {api_settings.NON_FIELD_ERRORS_KEY: "WebAuthn verification failed."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = user_serializer.save() + co.challenge = "" + co.user = user + co.sign_count = webauthn_credential.sign_count + co.credential_id = webauthn_credential.credential_id.decode() + co.public_key = webauthn_credential.public_key.decode() + co.save() + signals.user_registered.send( + sender=self.__class__, user=user, request=self.request + ) + + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + return Response(user_serializer.data, status=status.HTTP_201_CREATED) + + +class LoginRequestView(APIView): + permission_classes = (AllowAny,) + + def post(self, request): + serializer = WebauthnLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + co = CredentialOptions.objects.get( + username=serializer.validated_data["username"] + ) + + co.challenge = create_challenge(32) + co.save() + + webauthn_user = WebAuthnUser( + user_id=co.ukey, + username=co.username, + display_name=co.display_name, + icon_url="", + credential_id=co.credential_id, + public_key=co.public_key, + sign_count=co.sign_count, + rp_id=settings.WEBAUTHN["RP_ID"], + ) + webauthn_assertion_options = WebAuthnAssertionOptions( + webauthn_user, co.challenge + ) + + return Response(webauthn_assertion_options.assertion_dict) + + +# this name looks good :) +class LoginView(APIView): + permission_classes = (AllowAny,) + serializer_class = settings.WEBAUTHN.LOGIN_SERIALIZER + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + co = user.credential_options + + webuathn_user = WebAuthnUser( + user_id=co.ukey, + username=user.username, + display_name=co.display_name, + icon_url="", + credential_id=co.credential_id, + public_key=co.public_key, + sign_count=co.sign_count, + rp_id=settings.WEBAUTHN["RP_ID"], + ) + + webauthn_assertion_response = WebAuthnAssertionResponse( + webuathn_user, + request.data, + co.challenge, + settings.WEBAUTHN["ORIGIN"], + uv_required=False, + ) + + try: + sign_count = webauthn_assertion_response.verify() + except AuthenticationRejectedException: + return Response( + {api_settings.NON_FIELD_ERRORS_KEY: "WebAuthn verification failed."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + co.sign_count = sign_count + co.challenge = "" + co.save() + + token_serializer_class = settings.SERIALIZERS.token + token = login_user(request, user) + return Response( + token_serializer_class(token).data, status=status.HTTP_201_CREATED + ) From a0e4f4a27ff7ac268a8720eafb55e17b3c013393 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:19:10 +0000 Subject: [PATCH 02/10] Write user model tests --- b2b/core/migrations/0001_initial.py | 131 ---------------------------- b2b/core/migrations/__init__.py | 0 b2b/core/models.py | 8 -- b2b/user/__init__.py | 1 + b2b/user/apps.py | 9 ++ b2b/user/tests/__init__.py | 1 + b2b/user/tests/conftest.py | 64 ++++++++++++++ b2b/user/tests/test_models.py | 116 ++++++++++++++++++++++++ 8 files changed, 191 insertions(+), 139 deletions(-) delete mode 100644 b2b/core/migrations/0001_initial.py delete mode 100644 b2b/core/migrations/__init__.py delete mode 100644 b2b/core/models.py create mode 100644 b2b/user/__init__.py create mode 100644 b2b/user/apps.py create mode 100644 b2b/user/tests/__init__.py create mode 100644 b2b/user/tests/conftest.py create mode 100644 b2b/user/tests/test_models.py diff --git a/b2b/core/migrations/0001_initial.py b/b2b/core/migrations/0001_initial.py deleted file mode 100644 index 833e45b..0000000 --- a/b2b/core/migrations/0001_initial.py +++ /dev/null @@ -1,131 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-11 10:27 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), - ], - options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, - }, - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/b2b/core/migrations/__init__.py b/b2b/core/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/b2b/core/models.py b/b2b/core/models.py deleted file mode 100644 index ae77339..0000000 --- a/b2b/core/models.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Models for the core app.""" -from django.contrib.auth.models import AbstractUser - - -class User(AbstractUser): - """User model definition.""" - - pass diff --git a/b2b/user/__init__.py b/b2b/user/__init__.py new file mode 100644 index 0000000..d0f053c --- /dev/null +++ b/b2b/user/__init__.py @@ -0,0 +1 @@ +"""The user app.""" diff --git a/b2b/user/apps.py b/b2b/user/apps.py new file mode 100644 index 0000000..6ed70e5 --- /dev/null +++ b/b2b/user/apps.py @@ -0,0 +1,9 @@ +"""User app configuration.""" +from django.apps import AppConfig + + +class UserConfig(AppConfig): + """User app configuration class.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "user" diff --git a/b2b/user/tests/__init__.py b/b2b/user/tests/__init__.py new file mode 100644 index 0000000..965f001 --- /dev/null +++ b/b2b/user/tests/__init__.py @@ -0,0 +1 @@ +"""User app tests.""" diff --git a/b2b/user/tests/conftest.py b/b2b/user/tests/conftest.py new file mode 100644 index 0000000..55adfd8 --- /dev/null +++ b/b2b/user/tests/conftest.py @@ -0,0 +1,64 @@ +"""Common pytest fixtures for the user app.""" +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from model_bakery import baker + +JWT_CREATE_URL = reverse("user:jwt-create") +User = get_user_model() + + +@pytest.fixture +def sample_payload(): + """Return sample user information as a payload.""" + return { + "username": "sample_username", + "email": "user@example.com", + "password": "test_pass_123", + "first_name": "First name", + "last_name": "Last name", + } + + +@pytest.fixture +def create_jwt(sample_user, api_client): + """Create and return token pair for sample_user.""" + payload = { + "username": sample_user.username, + "password": "some password", + } + + response = api_client.post(JWT_CREATE_URL, payload) + + access = response.data.get("access", None) + refresh = response.data.get("refresh", None) + return access, refresh, response.status_code + + +@pytest.fixture +def sample_user(): + """Create and return a sample user.""" + return User.objects.create_user( + username="some_user_name", + email="someemail@example.com", + password="some password", + first_name="first name", + last_name="last name", + ) + + +@pytest.fixture +def inactive_user(): + """Create and return an inactive user.""" + return baker.make(User, is_active=False) + + +@pytest.fixture +def user_payload(): + """Return a payload of sample user information.""" + return { + "username": "sample_user_name", + "email": "user@example.com", + "password": "test_pass123", + "re_password": "test_pass123", + } diff --git a/b2b/user/tests/test_models.py b/b2b/user/tests/test_models.py new file mode 100644 index 0000000..c5ea84b --- /dev/null +++ b/b2b/user/tests/test_models.py @@ -0,0 +1,116 @@ +import pytest +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.utils import IntegrityError + +User = get_user_model() + + +@pytest.mark.django_db +class TestUserModel: + def test_creating_a_user_is_successful(self, sample_payload): + """Test that users are created successfully.""" + user = User.objects.create_user(**sample_payload) + + assert user.username == str(user) == sample_payload.get("username") + assert user.email == sample_payload.get("email") + assert user.first_name == sample_payload.get("first_name") + assert user.last_name == sample_payload.get("last_name") + assert not user.is_staff + assert not user.is_superuser + assert user.check_password(sample_payload.get("password")) + assert User.objects.all().count() == 1 + + def test_new_user_email_normalized(self): + """Test email normalization on user creation.""" + sample_emails = [ + ["test1@EXAMPLE.com", "test1@example.com"], + ["Test2@Example.com", "Test2@example.com"], + ["TEST3@EXAMPLE.COM", "TEST3@example.com"], + ["test4@example.COM", "test4@example.com"], + ] + + for email, expected in sample_emails: + user = User.objects.create_user( + username=expected, + email=email, + first_name="Sample first name", + password="testPass12345", + ) + + assert user.email == expected + + def test_create_user_missing_username_fails(self, sample_payload): + """Test creating user without username raises an error.""" + sample_payload.update({"username": None}) + + with pytest.raises(ValueError) as excinfo: + User.objects.create_user(**sample_payload) + assert str(excinfo.value) == "The given username must be set" + assert User.objects.all().count() == 0 + + def test_create_user_missing_email_validation(self, sample_payload): + """Test validating user without email raises an error.""" + sample_payload.update({"email": None}) + + user = User.objects.create_user(**sample_payload) + with pytest.raises(ValidationError) as excinfo: + user.full_clean() + assert ( + str(excinfo.value.message_dict["email"][0]) == "This field cannot be blank." + ) + + def test_create_user_missing_first_name_fails(self, sample_payload): + """Test creating user without first name raises an error.""" + sample_payload.update({"first_name": None}) + + with transaction.atomic(): + with pytest.raises(IntegrityError): + User.objects.create_user(**sample_payload) + assert User.objects.all().count() == 0 + + def test_create_user_with_existing_username_fails(self, sample_payload): + """Test creating user with existing username raises an error.""" + User.objects.create(**sample_payload) + sample_payload.update({"email": "different@example.com"}) + + # create a new user with the same username + with transaction.atomic(): + with pytest.raises(IntegrityError): + User.objects.create(**sample_payload) + assert User.objects.all().count() == 1 + + def test_create_user_with_existing_email_fails(self, sample_payload): + """Test creating user with existing email raises an error.""" + User.objects.create(**sample_payload) + sample_payload.update({"username": "other_name"}) + + # create a new user with the same email + with transaction.atomic(): + with pytest.raises(IntegrityError): + User.objects.create(**sample_payload) + assert User.objects.all().count() == 1 + + def test_create_superuser_successful(self, sample_payload): + """Test creating a superuser is successful.""" + user = User.objects.create_superuser(**sample_payload) + assert user.email == sample_payload.get("email") + assert user.username == sample_payload.get("username") + assert user.is_staff + assert user.is_superuser + + def test_create_superuser_missing_is_staff(self, sample_payload): + """Test creating a superuser with is_staff set to false fails.""" + sample_payload.update({"is_staff": False}) + + with pytest.raises(ValueError) as excinfo: + get_user_model().objects.create_superuser(**sample_payload) + assert str(excinfo.value) == "Superuser must have is_staff=True." + + def test_create_superuser_missing_is_superuser(self, sample_payload): + """Test creating a superuser with is_superuser set to false fails.""" + sample_payload.update({"is_superuser": False}) + with pytest.raises(ValueError) as excinfo: + get_user_model().objects.create_superuser(**sample_payload) + assert str(excinfo.value) == "Superuser must have is_superuser=True." From 4da542cccb95b30d130350566da7b52b2dedc44d Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:20:29 +0000 Subject: [PATCH 03/10] Build user model --- b2b/user/migrations/0001_initial.py | 131 ++++++++++++++++++++++++++++ b2b/user/migrations/__init__.py | 0 b2b/user/models.py | 18 ++++ 3 files changed, 149 insertions(+) create mode 100644 b2b/user/migrations/0001_initial.py create mode 100644 b2b/user/migrations/__init__.py create mode 100644 b2b/user/models.py diff --git a/b2b/user/migrations/0001_initial.py b/b2b/user/migrations/0001_initial.py new file mode 100644 index 0000000..df43d66 --- /dev/null +++ b/b2b/user/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 4.1.7 on 2023-03-11 13:12 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/b2b/user/migrations/__init__.py b/b2b/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/b2b/user/models.py b/b2b/user/models.py new file mode 100644 index 0000000..dfc87d8 --- /dev/null +++ b/b2b/user/models.py @@ -0,0 +1,18 @@ +"""Models for the core app.""" +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class User(AbstractUser): + """Custom user model for this project.""" + + email = models.EmailField(_("email address"), blank=False, null=False, unique=True) + + REQUIRED_FIELDS = [ + "email", + ] + + def __str__(self): + """Return username.""" + return self.username From c8a9a05ad5534bc79f24589e3685aed871f6f939 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:21:00 +0000 Subject: [PATCH 04/10] Write user api tests --- b2b/conftest.py | 9 + b2b/user/tests/test_google_auth.py | 130 ++++ b2b/user/tests/test_user_api.py | 1002 ++++++++++++++++++++++++++++ 3 files changed, 1141 insertions(+) create mode 100644 b2b/conftest.py create mode 100644 b2b/user/tests/test_google_auth.py create mode 100644 b2b/user/tests/test_user_api.py diff --git a/b2b/conftest.py b/b2b/conftest.py new file mode 100644 index 0000000..330710a --- /dev/null +++ b/b2b/conftest.py @@ -0,0 +1,9 @@ +"""Global project fixtures.""" +import pytest +from rest_framework.test import APIClient + + +@pytest.fixture +def api_client(): + """Return API client.""" + return APIClient() diff --git a/b2b/user/tests/test_google_auth.py b/b2b/user/tests/test_google_auth.py new file mode 100644 index 0000000..6284656 --- /dev/null +++ b/b2b/user/tests/test_google_auth.py @@ -0,0 +1,130 @@ +from unittest import mock + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import TestCase +from model_bakery import baker +from rest_framework.test import APIClient +from social_django.views import get_session_timeout + +User = get_user_model() + +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET +SOCIAL_AUTH_ALLOWED_REDIRECT_URIS = settings.DJOSER["SOCIAL_AUTH_ALLOWED_REDIRECT_URIS"] +GOOGLE_AUTH_URL = "/user/o/google-oauth2/" + + +@pytest.fixture +def api_client(): + """Return API client.""" + return APIClient() + + +@pytest.mark.django_db +class TestGoogleAuth: + def test_get_authorization_url_returns_200(self, api_client): + """Test get authorization url is successful.""" + redirect_uri = SOCIAL_AUTH_ALLOWED_REDIRECT_URIS[0] + url = f"{GOOGLE_AUTH_URL}?redirect_uri={redirect_uri}" + + response = api_client.get(url) + + assert response.status_code == 200 + assert "authorization_url" in response.data + + def test_get_authorization_url_fails_if_redirect_uri_invalid(self, api_client): + """Test invalid redirect url does not return authorization uri.""" + redirect_uri = "http://invalid-url.com" + url = f"{GOOGLE_AUTH_URL}?redirect_uri={redirect_uri}" + + response = api_client.get(url) + + assert response.status_code == 400 + assert ( + response.data == "redirect_uri must be in SOCIAL_AUTH_ALLOWED_REDIRECT_URIS" + ) + + def test_final_google_authentication_successful(self, api_client, mocker): + """Test final authentication process is successful.""" + mocker.patch( + "djoser.social.serializers.ProviderAuthSerializer.validate", + return_value={"user": baker.make(User)}, + ) + + response = api_client.post(f"{GOOGLE_AUTH_URL}?code=code&state=state") + + assert response.status_code == 201 + assert User.objects.all().count() == 1 + assert "access" in response.data + assert "refresh" in response.data + assert "user" in response.data + + +class TestGetSessionTimeout(TestCase): + """ + Ensure that the branching logic of get_session_timeout behaves as expected. + """ + + def setUp(self): + self.social_user = mock.MagicMock() + self.social_user.expiration_datetime.return_value = None + super().setUp() + + def set_user_expiration(self, seconds): + self.social_user.expiration_datetime.return_value = mock.MagicMock( + total_seconds=mock.MagicMock(return_value=seconds) + ) + + def test_expiration_disabled_no_max(self): + self.set_user_expiration(60) + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=False + ) + self.assertIsNone(expiration_length) + + def test_expiration_disabled_with_max(self): + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=False, max_session_length=60 + ) + self.assertEqual(expiration_length, 60) + + def test_expiration_disabled_with_zero_max(self): + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=False, max_session_length=0 + ) + self.assertEqual(expiration_length, 0) + + def test_user_has_session_length_no_max(self): + self.set_user_expiration(60) + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=True + ) + self.assertEqual(expiration_length, 60) + + def test_user_has_session_length_larger_max(self): + self.set_user_expiration(60) + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=True, max_session_length=90 + ) + self.assertEqual(expiration_length, 60) + + def test_user_has_session_length_smaller_max(self): + self.set_user_expiration(60) + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=True, max_session_length=30 + ) + self.assertEqual(expiration_length, 30) + + def test_user_has_no_session_length_with_max(self): + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=True, max_session_length=60 + ) + self.assertEqual(expiration_length, 60) + + def test_user_has_no_session_length_no_max(self): + expiration_length = get_session_timeout( + self.social_user, enable_session_expiration=True + ) + self.assertIsNone(expiration_length) diff --git a/b2b/user/tests/test_user_api.py b/b2b/user/tests/test_user_api.py new file mode 100644 index 0000000..31fc1d9 --- /dev/null +++ b/b2b/user/tests/test_user_api.py @@ -0,0 +1,1002 @@ +"""Tests for the User API.""" +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core import mail +from django.urls import reverse +from djoser import utils +from djoser.serializers import UserSerializer +from rest_framework import status +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken + +User = get_user_model() +REQUIRED_FIELD_ERROR = ["This field may not be blank."] +USER_CREATE_URL = reverse("user:user-list") +USER_ACTIVATION_URL = reverse("user:user-activation") +USER_RESEND_ACTIVATION_URL = reverse("user:user-resend-activation") +USER_URL = reverse("user:user-me") +SET_USERNAME_URL = reverse("user:user-set-username") +RESET_USERNAME_URL = reverse("user:user-reset-username") +RESET_USERNAME_CONFIRM_URL = reverse("user:user-reset-username-confirm") +SET_PASSWORD_URL = reverse("user:user-set-password") +RESET_PASSWORD_URL = reverse("user:user-reset-password") +RESET_PASSWORD_CONFIRM_URL = reverse("user:user-reset-password-confirm") +JWT_CREATE_URL = reverse("user:jwt-create") +JWT_REFRESH_URL = reverse("user:jwt-refresh") +JWT_VERIFY_URL = reverse("user:jwt-verify") +JWT_BLACKLIST_URL = reverse("user:jwt-blacklist") + + +@pytest.mark.django_db +class TestUserCreate: + def test_create_user_returns_201(self, api_client, user_payload): + """Test creating a user is successful and email is sent.""" + + response = api_client.post(USER_CREATE_URL, user_payload) + + created_user = User.objects.get(username=user_payload.get("username")) + assert response.status_code == status.HTTP_201_CREATED + assert created_user.username == user_payload.get("username") + assert created_user.email == user_payload.get("email") + assert not created_user.is_active + assert created_user.check_password(user_payload.get("password")) + # Make sure the password is not returned to the user: + assert "password" not in response.data + # Assert email was sent to created user: + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [created_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_create_user_if_username_exists_returns_400( + self, api_client, sample_user, user_payload + ): + """Test create user with a username that already exists returns error.""" + user_payload.update({"username": sample_user.username}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("username") == [ + "A user with that username already exists." + ] + assert User.objects.all().count() == 1 + assert len(mail.outbox) == 0 + + def test_create_user_if_email_exists_returns_400( + self, sample_user, api_client, user_payload + ): + """Test create user with an email that already exists returns error.""" + user_payload.update({"email": sample_user.email}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("email") == [ + "user with this email address already exists." + ] + assert User.objects.all().count() == 1 + assert len(mail.outbox) == 0 + + def test_create_user_without_email_returns_400(self, api_client, user_payload): + """Test create user without an email returns error.""" + user_payload.update({"email": ""}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("email") == REQUIRED_FIELD_ERROR + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_without_username_returns_400(self, api_client, user_payload): + """Test create user without a username returns error.""" + user_payload.update({"username": ""}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("username") == REQUIRED_FIELD_ERROR + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_with_short_password_returns_400( + self, api_client, user_payload + ): + """Test create user with short password returns error.""" + user_payload.update({"password": "wf9283y", "re_password": "wf9283y"}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == [ + "This password is too short. It must contain at least 8 characters." + ] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_with_numeric_password_returns_400( + self, api_client, user_payload + ): + """Test create user with numeric password returns error.""" + user_payload.update({"password": "48912734", "re_password": "48912734"}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == ["This password is entirely numeric."] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_with_common_password_returns_400( + self, api_client, user_payload + ): + """Test create user with common password returns error.""" + user_payload.update({"password": "password", "re_password": "password"}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == ["This password is too common."] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_with_password_similar_to_email_returns_400( + self, api_client, user_payload + ): + """Test create user with password similar to email returns error.""" + user_payload.update({"password": "@example", "re_password": "@example"}) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == [ + "The password is too similar to the email address." + ] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_with_password_similar_to_username_returns_400( + self, api_client, user_payload + ): + """Test create user with password similar to username returns error.""" + user_payload.update( + {"password": "sampleusername", "re_password": "sampleusername"} + ) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == [ + "The password is too similar to the username." + ] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + def test_create_user_if_passwords_dont_match_returns_400( + self, api_client, user_payload + ): + """Test create user with non-matching passwords returns error.""" + user_payload.update( + {"password": "test_pass123", "re_password": "different_pass123"} + ) + + response = api_client.post(USER_CREATE_URL, user_payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("non_field_errors") == [ + "The two password fields didn't match." + ] + assert User.objects.all().count() == 0 + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestUserActivation: + def test_activate_user_with_valid_token_returns_204( + self, api_client, inactive_user + ): + """Test that inactive user is activated with valid uid and token.""" + payload = { + "uid": utils.encode_uid(inactive_user.pk), + "token": default_token_generator.make_token(inactive_user), + } + + response = api_client.post(USER_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + inactive_user.refresh_from_db() + assert inactive_user.is_active + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [inactive_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_user_already_active_returns_403(self, api_client, sample_user): + """Test that user already active returns error 403.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + } + + response = api_client.post(USER_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data.get("detail") == "Stale token for given user." + assert len(mail.outbox) == 0 + + def test_invalid_uid_returns_400(self, api_client, inactive_user): + """Test invalid uid returns error.""" + payload = { + "uid": "invalid_uid", + "token": default_token_generator.make_token(inactive_user), + } + + response = api_client.post(USER_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("uid") == ["Invalid user id or user doesn't exist."] + inactive_user.refresh_from_db() + assert not inactive_user.is_active + assert len(mail.outbox) == 0 + + def test_invalid_token_returns_400(self, api_client, inactive_user): + """Test invalid token returns error.""" + payload = { + "uid": utils.encode_uid(inactive_user.pk), + "token": "invalid_token", + } + + response = api_client.post(USER_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("token") == ["Invalid token for given user."] + inactive_user.refresh_from_db() + assert not inactive_user.is_active + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestUserResendActivationEmail: + def test_activation_email_sent(self, api_client, inactive_user): + """Test that the activation email is sent upon valid request.""" + payload = {"email": inactive_user.email} + + response = api_client.post(USER_RESEND_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [inactive_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_if_user_already_active_returns_400(self, api_client, sample_user): + """Test that activation email is not sent if the user is already active.""" + payload = {"email": sample_user.email} + + response = api_client.post(USER_RESEND_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert len(mail.outbox) == 0 + + def test_if_email_does_not_exist_returns_400(self, api_client): + """Test that the activation email is not sent if the email does not exist.""" + payload = {"email": "non_existent@example.com"} + + response = api_client.post(USER_RESEND_ACTIVATION_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestUser: + def test_get_user_info_returns_200(self, api_client, sample_user): + """Test retrieve user successful.""" + api_client.force_authenticate(user=sample_user) + + response = api_client.get(USER_URL) + + serializer = UserSerializer(sample_user) + assert response.status_code == status.HTTP_200_OK + assert response.data == serializer.data + + def test_anonymous_user_get_user_info_returns_401(self, api_client, sample_user): + """Test anonymous user retrieve user returns error.""" + response = api_client.get(USER_URL) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "Authentication credentials were not provided." + ) + serializer = UserSerializer(sample_user) + assert response.data != serializer.data + + def test_full_update_user(self, api_client, sample_user): + """Test full update user successful.""" + api_client.force_authenticate(user=sample_user) + payload = { + "email": "user@example.com", + } + + response = api_client.put(USER_URL, payload) + + assert response.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + serializer = UserSerializer(sample_user) + assert response.data == serializer.data + assert sample_user.email == payload.get("email") + assert not sample_user.is_active + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_partial_update_user(self, api_client, sample_user): + """Test partial update user successful.""" + api_client.force_authenticate(user=sample_user) + payload = {"email": "user@example.com"} + + response = api_client.patch(USER_URL, payload) + + assert response.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + serializer = UserSerializer(sample_user) + assert response.data == serializer.data + assert sample_user.email == payload.get("email") + assert not sample_user.is_active + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_update_email_deactivates_user(self, api_client, sample_user): + """Test user is deactivated and email is sent on email update.""" + api_client.force_authenticate(user=sample_user) + payload = {"email": "user@example.com"} + + response = api_client.patch(USER_URL, payload) + + assert response.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + serializer = UserSerializer(sample_user) + assert response.data == serializer.data + assert sample_user.email == payload.get("email") + assert not sample_user.is_active + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_update_email_with_same_email_does_not_deactivate( + self, api_client, sample_user + ): + """Test user is not deactivated when email does not change.""" + api_client.force_authenticate(user=sample_user) + payload = {"email": sample_user.email} + + response = api_client.patch(USER_URL, payload) + + assert response.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + serializer = UserSerializer(sample_user) + assert response.data == serializer.data + assert sample_user.email == payload.get("email") + assert sample_user.is_active + assert len(mail.outbox) == 0 + + def test_anonymous_user_full_update_profile_returns_401( + self, api_client, sample_user + ): + """Test anonymous user cannot perform full update.""" + old_email = sample_user.email + payload = { + "email": "user@example.com", + } + + response = api_client.put(USER_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "Authentication credentials were not provided." + ) + sample_user.refresh_from_db() + serializer = UserSerializer(sample_user) + assert response.data != serializer.data + assert sample_user.email == old_email + assert sample_user.is_active + assert len(mail.outbox) == 0 + + def test_delete_user_returns_204(self, api_client, sample_user): + """Test delete user successful.""" + api_client.force_authenticate(user=sample_user) + payload = {"current_password": "some password"} + + response = api_client.delete(USER_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert User.objects.all().count() == 0 + + def test_anonymous_user_delete_user_returns_401(self, api_client, sample_user): + """Test anonymous user cannot perform delete action.""" + payload = {"current_password": "some password"} + + response = api_client.delete(USER_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "Authentication credentials were not provided." + ) + assert User.objects.all().count() == 1 + + def test_delete_user_with_wrong_password_returns_400(self, api_client, sample_user): + """Test delete action fails with wrong password.""" + api_client.force_authenticate(user=sample_user) + payload = {"current_password": "incorrect_password"} + + response = api_client.delete(USER_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert User.objects.all().count() == 1 + + +@pytest.mark.django_db +class TestSetUsername: + def test_set_username_returns_204(self, api_client, sample_user): + """Test set username is successful.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_username": "new_username", + "re_new_username": "new_username", + "current_password": "some password", + } + + response = api_client.post(SET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.username == payload.get("new_username") + + def test_set_username_with_wrong_password_returns_400( + self, api_client, sample_user + ): + """Test set username with wrong password fails.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_username": "new_username", + "re_new_username": "new_username", + "current_password": "incorrect password", + } + + response = api_client.post(SET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + + def test_if_usernames_do_not_match_returns_400(self, api_client, sample_user): + """Test set username with non-matching usernames fails.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_username": "new_username", + "re_new_username": "different_username", + "current_password": "some password", + } + + response = api_client.post(SET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("non_field_errors") == [ + "The two username fields didn't match." + ] + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + + def test_anonymous_user_set_username_returns_401(self, api_client, sample_user): + """Test anonymous user set username fails.""" + payload = { + "new_username": "new_username", + "re_new_username": "new_username", + "current_password": "some password", + } + + response = api_client.post(SET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "Authentication credentials were not provided." + ) + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + + +@pytest.mark.django_db +class TestResetUsername: + def test_username_reset_email_sent(self, api_client, sample_user): + """Test that the username reset email is sent upon valid request.""" + payload = {"email": sample_user.email} + + response = api_client.post(RESET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_if_email_does_not_exist_returns_204(self, api_client): + """Test that the username reset email is not sent if email does not exist.""" + payload = {"email": "non_existent@example.com"} + + response = api_client.post(RESET_USERNAME_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestResetUsernameConfirmation: + def test_reset_username_with_valid_token_returns_204(self, api_client, sample_user): + """Test that a username is reset with a valid uid and token.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + "new_username": "new_username", + "re_new_username": "new_username", + } + + response = api_client.post(RESET_USERNAME_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.username == payload.get("new_username") + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_invalid_uid_returns_400(self, api_client, sample_user): + """Test an invalid uid returns error.""" + payload = { + "uid": "invalid_uid", + "token": default_token_generator.make_token(sample_user), + "new_username": "new_username", + "re_new_username": "new_username", + } + + response = api_client.post(RESET_USERNAME_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("uid") == ["Invalid user id or user doesn't exist."] + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + assert len(mail.outbox) == 0 + + def test_invalid_token_returns_400(self, api_client, sample_user): + """Test invalid token returns error.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": "invalid_token", + "new_username": "new_username", + "re_new_username": "new_username", + } + + response = api_client.post(RESET_USERNAME_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("token") == ["Invalid token for given user."] + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + assert len(mail.outbox) == 0 + + def test_non_matching_usernames_returns_400(self, api_client, sample_user): + """Test reset username with non-matching usernames fails.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + "new_username": "new_username", + "re_new_username": "different_username", + } + + response = api_client.post(RESET_USERNAME_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("non_field_errors") == [ + "The two username fields didn't match." + ] + sample_user.refresh_from_db() + assert sample_user.username != payload.get("new_username") + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestSetPassword: + def test_set_password_returns_204(self, api_client, sample_user): + """Test set password is successful.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_password": "new_password", + "re_new_password": "new_password", + "current_password": "some password", + } + + response = api_client.post(SET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.check_password(payload.get("new_password")) + + def test_set_password_with_wrong_password_returns_400( + self, api_client, sample_user + ): + """Test set password with wrong password fails.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_password": "new_password", + "re_new_password": "new_password", + "current_password": "incorrect password", + } + + response = api_client.post(SET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("current_password") == ["Invalid password."] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + + def test_if_passwords_do_not_match_returns_400(self, api_client, sample_user): + """Test set password with non-matching passwords fails.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_password": "new_password", + "re_new_password": "different_password", + "current_password": "some password", + } + + response = api_client.post(SET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("non_field_errors") == [ + "The two password fields didn't match." + ] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + + def test_weak_password_returns_400(self, api_client, sample_user): + """Test error is returned upon submitting a weak password.""" + api_client.force_authenticate(user=sample_user) + payload = { + "new_password": "1234567", + "re_new_password": "1234567", + "current_password": "some password", + } + + response = api_client.post(SET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + # assert 3 password errors; numeric, short and common are returned: + assert response.data.get("new_password") == [ + "This password is too short. It must contain at least 8 characters.", + "This password is too common.", + "This password is entirely numeric.", + ] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + + def test_anonymous_user_set_password_returns_401(self, api_client, sample_user): + """Test anonymous user set password fails.""" + payload = { + "new_password": "new_password", + "re_new_password": "new_password", + "current_password": "some password", + } + + response = api_client.post(SET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + sample_user.refresh_from_db() + assert ( + response.data.get("detail") + == "Authentication credentials were not provided." + ) + assert not sample_user.check_password(payload.get("new_password")) + + +@pytest.mark.django_db +class TestResetPassword: + def test_password_reset_email_sent(self, api_client, sample_user): + """Test that the password reset email is sent upon valid request.""" + payload = {"email": sample_user.email} + + response = api_client.post(RESET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_if_email_does_not_exist_returns_204(self, api_client): + """Test that the password reset email is not sent if email does not exist.""" + payload = {"email": "non_existent@example.com"} + + response = api_client.post(RESET_PASSWORD_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +class TestResetPasswordConfirmation: + def test_reset_password_with_valid_token_returns_204(self, api_client, sample_user): + """Test that password is reset with valid uid and token.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + "new_password": "new_password", + "re_new_password": "new_password", + } + + response = api_client.post(RESET_PASSWORD_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.check_password(payload.get("new_password")) + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [sample_user.email] + assert mail.outbox[0].from_email == settings.DEFAULT_FROM_EMAIL + + def test_invalid_uid_returns_400(self, api_client, sample_user): + """Test invalid uid returns error.""" + payload = { + "uid": "invalid_uid", + "token": default_token_generator.make_token(sample_user), + "new_password": "new_password", + "re_new_password": "new_password", + } + + response = api_client.post(RESET_PASSWORD_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("uid") == ["Invalid user id or user doesn't exist."] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + assert len(mail.outbox) == 0 + + def test_invalid_token_returns_400(self, api_client, sample_user): + """Test invalid token returns error.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": "invalid_token", + "new_password": "new_password", + "re_new_password": "new_password", + } + + response = api_client.post(RESET_PASSWORD_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("token") == ["Invalid token for given user."] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + assert len(mail.outbox) == 0 + + def test_non_matching_passwords_returns_400(self, api_client, sample_user): + """Test reset password with non-matching passwords fails.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + "new_password": "new_password", + "re_new_password": "different_password", + } + + response = api_client.post(RESET_PASSWORD_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("non_field_errors") == [ + "The two password fields didn't match." + ] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + assert len(mail.outbox) == 0 + + def test_weak_password_returns_400(self, api_client, sample_user): + """Test error is returned upon confirmation with weak password.""" + payload = { + "uid": utils.encode_uid(sample_user.pk), + "token": default_token_generator.make_token(sample_user), + "new_password": "1234567", + "re_new_password": "1234567", + } + + response = api_client.post(RESET_PASSWORD_CONFIRM_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + # assert 3 password errors; numeric, short and common are returned: + assert response.data.get("new_password") == [ + "This password is too short. It must contain at least 8 characters.", + "This password is too common.", + "This password is entirely numeric.", + ] + sample_user.refresh_from_db() + assert not sample_user.check_password(payload.get("new_password")) + + +@pytest.mark.django_db +class TestJWTCreate: + def test_create_jwt_returns_200(self, create_jwt): + """Test creating an access and refresh token is successful.""" + access, refresh, status_code = create_jwt + + assert status_code == status.HTTP_200_OK + assert access + assert refresh + + def test_create_jwt__with_invalid_username_returns_401( + self, api_client, sample_user + ): + """Test create jwt with invalid username fails.""" + payload = { + "username": "non_existing_user", + "password": "some password", + } + + response = api_client.post(JWT_CREATE_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "No active account found with the given credentials" + ) + assert "access" not in response.data + assert "refresh" not in response.data + + def test_create_jwt__with_invalid_password_returns_401( + self, api_client, sample_user + ): + """Test create jwt with invalid password fails.""" + payload = { + "username": sample_user.username, + "password": "incorrect password", + } + + response = api_client.post(JWT_CREATE_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert ( + response.data.get("detail") + == "No active account found with the given credentials" + ) + assert "access" not in response.data + assert "refresh" not in response.data + + def test_create_jwt_with_no_password_returns_400(self, api_client, sample_user): + """Test create jwt without password fails.""" + payload = { + "username": sample_user.username, + "password": "", + } + + response = api_client.post(JWT_CREATE_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("password") == REQUIRED_FIELD_ERROR + assert "access" not in response.data + assert "refresh" not in response.data + + +@pytest.mark.django_db +class TestJWTRefresh: + def test_refresh_access_token_returns_200(self, api_client, create_jwt): + """Test refresh access token is successful.""" + access, refresh, status_code = create_jwt + payload = {"refresh": refresh} + + response = api_client.post(JWT_REFRESH_URL, payload) + + assert response.status_code == status.HTTP_200_OK + assert "access" in response.data + assert "detail" not in response.data + assert "code" not in response.data + + def test_refresh_access_token_with_invalid_refresh_returns_400(self, api_client): + """Test refresh access token with invalid refresh token fails.""" + payload = {"refresh": "invalid token"} + + response = api_client.post(JWT_REFRESH_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data.get("detail") == "Token is invalid or expired" + assert response.data.get("code") == "token_not_valid" + assert "access" not in response.data + + def test_refresh_access_token_with_no_refresh_returns_400(self, api_client): + """Test refresh access token without refresh token fails.""" + payload = {"refresh": ""} + + response = api_client.post(JWT_REFRESH_URL, payload) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data.get("refresh") == REQUIRED_FIELD_ERROR + assert "access" not in response.data + + +@pytest.mark.django_db +class TestJWTVerify: + def test_verify_access_token_returns_200(self, api_client, create_jwt): + """Test verify access token successful for valid token.""" + access, refresh, status_code = create_jwt + payload = {"token": access} + + response = api_client.post(JWT_VERIFY_URL, payload) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + def test_verify_refresh_token_returns_200(self, api_client, create_jwt): + """Test verify refresh token successful for valid token.""" + access, refresh, status_code = create_jwt + payload = {"token": refresh} + + response = api_client.post(JWT_VERIFY_URL, payload) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + def test_verify_jwt_with_invalid_token_returns_400(self, api_client): + """Test verify token returns error if token is invalid.""" + payload = {"token": "invalid token"} + + response = api_client.post(JWT_VERIFY_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data.get("detail") == "Token is invalid or expired" + assert response.data.get("code") == "token_not_valid" + + +@pytest.mark.django_db +class TestJWTBlacklist: + def test_blacklist_token_returns_200(self, api_client, create_jwt): + """Test blacklist refresh token successful for valid token.""" + _, refresh, _ = create_jwt + payload = {"refresh": refresh} + + response = api_client.post(JWT_BLACKLIST_URL, payload) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + assert BlacklistedToken.objects.all().count() == 1 + + response = api_client.post(JWT_REFRESH_URL, payload) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data == { + "detail": "Token is blacklisted", + "code": "token_not_valid", + } + + def test_blacklist_access_token_returns_401(self, api_client, create_jwt): + """Test blacklist access token unsuccessful.""" + access, _, _ = create_jwt + payload = {"refresh": access} + + response = api_client.post(JWT_BLACKLIST_URL, payload) + + print(response.data) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data == { + "detail": "Token has wrong type", + "code": "token_not_valid", + } + assert BlacklistedToken.objects.all().count() == 0 + + def test_blacklist_blacklisted_token_returns_401(self, api_client, create_jwt): + """Test blacklist already blacklisted token returns error.""" + _, refresh, _ = create_jwt + payload = {"refresh": refresh} + api_client.post(JWT_BLACKLIST_URL, payload) + + response = api_client.post(JWT_BLACKLIST_URL, payload) + + print(response.data) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data == { + "detail": "Token is blacklisted", + "code": "token_not_valid", + } + assert BlacklistedToken.objects.all().count() == 1 From 734c528bd2c233d13a8d60284bb742ae062eacc7 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:24:04 +0000 Subject: [PATCH 05/10] Build user API --- b2b/b2b/settings.py | 126 +++++++++++++++++++++++++++++++++++++++++++- b2b/b2b/urls.py | 9 +++- b2b/user/urls.py | 12 +++++ docker-compose.yml | 11 ++-- 4 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 b2b/user/urls.py diff --git a/b2b/b2b/settings.py b/b2b/b2b/settings.py index 8ac00a7..cde1ed3 100644 --- a/b2b/b2b/settings.py +++ b/b2b/b2b/settings.py @@ -11,6 +11,7 @@ """ import os +from datetime import timedelta from typing import List import environ @@ -46,11 +47,21 @@ "django.contrib.staticfiles", # Local apps "core.apps.CoreConfig", + "user.apps.UserConfig", + # Third-party apps + "corsheaders", + "djoser", + "rest_framework", + "rest_framework_simplejwt", + "rest_framework_simplejwt.token_blacklist", + "social_django", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "social_django.middleware.SocialAuthExceptionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -71,6 +82,8 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", ], }, }, @@ -131,4 +144,115 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -AUTH_USER_MODEL = "core.User" +AUTH_USER_MODEL = "user.User" + +if DEBUG: + # Configure django debug toolbar for development + import socket + + INSTALLED_APPS += [ + "debug_toolbar", + ] + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + ] + + INTERNAL_IPS = [ + "127.0.0.1", + "0.0.0.0:8000", + ] + + hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) + INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + [ + "127.0.0.1", + "10.0.2.2", + "0.0.0.0:8000", + ] + + # Support static files in development + STATIC_ROOT = "/vol/web/static" + +STATIC_ROOT = os.path.join(BASE_DIR, "static") + +CORS_ALLOWED_ORIGINS: List[str] = list( + filter(None, env("CORS_ALLOWED_ORIGINS").split(",")) +) +CORS_ALLOW_CREDENTIALS = True + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), +} + +SIMPLE_JWT = { + "AUTH_HEADER_TYPES": ("JWT",), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), +} +if DEBUG: + SIMPLE_JWT.update({"ACCESS_TOKEN_LIFETIME": timedelta(days=30)}) + + +DOMAIN = env("CLIENT_URL") +SITE_NAME = "b2b" +DJOSER = { + "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", + "USERNAME_RESET_CONFIRM_URL": "username/reset/confirm/{uid}/{token}", + "ACTIVATION_URL": "activate/{uid}/{token}", + "SEND_ACTIVATION_EMAIL": True, + "SEND_CONFIRMATION_EMAIL": True, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, + "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, + "SET_USERNAME_RETYPE": True, + "SET_PASSWORD_RETYPE": True, + "USER_CREATE_PASSWORD_RETYPE": True, + "USERNAME_RESET_CONFIRM_RETYPE": True, + "PASSWORD_RESET_CONFIRM_RETYPE": True, + "TOKEN_MODEL": None, + "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", + "SERIALIZERS": {}, +} +DJOSER["SOCIAL_AUTH_ALLOWED_REDIRECT_URIS"] = list( + filter(None, env("SOCIAL_AUTH_ALLOWED_REDIRECT_URIS").split(",")) +) + +AUTHENTICATION_BACKENDS = ( + "social_core.backends.google.GoogleOAuth2", + "django.contrib.auth.backends.ModelBackend", +) + +SOCIAL_AUTH_PIPELINE = ( + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.social_auth.associate_by_email", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", +) +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = env("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = env("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ + "https://www.googleapis.com/auth/userinfo.email", +] +SOCIAL_AUTH_PIPELINE = ( + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.social_auth.associate_by_email", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", +) + +EMAIL_HOST = env("EMAIL_HOST") +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") +EMAIL_PORT = env("EMAIL_PORT") +DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") diff --git a/b2b/b2b/urls.py b/b2b/b2b/urls.py index ddde8d3..d7ad925 100644 --- a/b2b/b2b/urls.py +++ b/b2b/b2b/urls.py @@ -13,9 +13,16 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("user/", include("user.urls")), ] + +if settings.DEBUG: + urlpatterns += [ + path(r"__debug__/", include("debug_toolbar.urls")), + ] diff --git a/b2b/user/urls.py b/b2b/user/urls.py new file mode 100644 index 0000000..dce057a --- /dev/null +++ b/b2b/user/urls.py @@ -0,0 +1,12 @@ +"""URL configurations for the user app.""" +from django.urls import include, path +from rest_framework_simplejwt.views import TokenBlacklistView + +app_name = "user" + +urlpatterns = [ + path("", include("djoser.urls")), + path("", include("djoser.urls.jwt")), + path("", include("djoser.social.urls")), + path("jwt/blacklist/", TokenBlacklistView.as_view(), name="jwt-blacklist"), +] diff --git a/docker-compose.yml b/docker-compose.yml index 080b8aa..2a07749 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,14 +16,19 @@ services: python manage.py migrate && python manage.py runserver 0.0.0.0:8000" environment: - - DEBUG=${DEBUG} + - CLIENT_URL=${CLIENT_URL} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS} - DATABASE_URL=${DATABASE_URL} - - SECRET_KEY=changeme + - DEBUG=${DEBUG} + - DEFAULT_FROM_EMAIL=from@snnbotchway.com - EMAIL_HOST=smtp4dev - EMAIL_HOST_USER= - EMAIL_HOST_PASSWORD= - EMAIL_PORT=25 - - DEFAULT_FROM_EMAIL=from@snnbotchway.com + - SECRET_KEY=${SECRET_KEY} + - SOCIAL_AUTH_ALLOWED_REDIRECT_URIS=${SOCIAL_AUTH_ALLOWED_REDIRECT_URIS} + - SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=${SOCIAL_AUTH_GOOGLE_OAUTH2_KEY} + - SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=${SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET} depends_on: - db - smtp4dev From e735ae3c3a8f59ffd5df6cb353fde6c1dc9334a3 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:24:46 +0000 Subject: [PATCH 06/10] Update sample .env --- .env.sample | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.sample b/.env.sample index cb508c1..9f24ac9 100644 --- a/.env.sample +++ b/.env.sample @@ -6,3 +6,13 @@ SECRET_KEY=secret # database connection url string DATABASE_URL=postgresql://username:password@host:port/database + +# Google OAuth2 information +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY= +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET= + +# Client domain +CLIENT_URL=http://localhost:3000 + +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +SOCIAL_AUTH_ALLOWED_REDIRECT_URIS='comma-separated-list-of-allowed-redirect-uris-for-social authentication' From 2af5cb6b8ef5847f6935d4cd7d91c57bbe0eddfd Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:26:30 +0000 Subject: [PATCH 07/10] Configure user admin --- b2b/user/admin.py | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 b2b/user/admin.py diff --git a/b2b/user/admin.py b/b2b/user/admin.py new file mode 100644 index 0000000..499137c --- /dev/null +++ b/b2b/user/admin.py @@ -0,0 +1,82 @@ +"""User admin configuration.""" +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ + +User = get_user_model() + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Define admin model for custom User model.""" + + fieldsets = ( + ( + None, + { + "fields": ( + "email", + "username", + "password", + ), + }, + ), + ( + _("Personal info"), + { + "fields": ( + "first_name", + "last_name", + ), + }, + ), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + ( + _("Important dates"), + { + "fields": ( + "last_login", + "date_joined", + ) + }, + ), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "email", + "username", + "password1", + "password2", + ), + }, + ), + ) + list_display = ( + "email", + "username", + "is_staff", + ) + search_fields = ( + "email", + "username", + ) + ordering = ( + "email", + "username", + ) From 2bc627e5d395ce6a1504a64dcb69eb8d2da80dbc Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:27:00 +0000 Subject: [PATCH 08/10] Update requirements --- requirements.dev.txt | 3 +++ requirements.txt | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/requirements.dev.txt b/requirements.dev.txt index be18457..e3f8f9e 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,3 +1,6 @@ black>=23.1.0,<23.2 +django-debug-toolbar>=3.8.1,<3.9 +model-bakery>=1.10.1,<1.11 pre-commit>=3.1.1,<3.2 pytest-django>=4.5.2,<4.6 +pytest-mock>=3.10.0,<3.11 diff --git a/requirements.txt b/requirements.txt index 0eca5c7..99d4d3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ Django>=4.1.7,<4.2 django-environ>=0.10.0,<0.11 psycopg2>=2.9.5,<2.10 +djangorestframework>=3.14.0,<3.15 +djangorestframework-simplejwt>=5.2.2,<5.3 +social-auth-app-django>=5.0.0,<5.1 +django-cors-headers>=3.13.0,<3.14 +django-templated-mail>=1.1.1,<1.2 From f89c37cb014560240177c0ec760e3e5bee58b91b Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:27:23 +0000 Subject: [PATCH 09/10] Update github actions --- .github/workflows/checks.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 89989fc..0d35c73 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -30,8 +30,12 @@ jobs: runs-on: ubuntu-latest needs: check-hooks env: + CORS_ALLOWED_ORIGINS: http://localhost:3000 DEBUG: True SECRET_KEY: change_me + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY }} + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET }} + SOCIAL_AUTH_ALLOWED_REDIRECT_URIS: http://127.0.0.1:3000/login steps: - name: Login to Docker Hub From c196642a0fb28d45eaa2047236e1d803596176c7 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Sat, 11 Mar 2023 15:36:51 +0000 Subject: [PATCH 10/10] Add db connection url to workflow file --- .github/workflows/checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0d35c73..e4c8699 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -31,6 +31,7 @@ jobs: needs: check-hooks env: CORS_ALLOWED_ORIGINS: http://localhost:3000 + DATABASE_URL: postgresql://devuser:changeme@db:5432/devdb DEBUG: True SECRET_KEY: change_me SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY }}