diff --git a/Makefile b/Makefile
index 7d0e8ccc..6c173211 100644
--- a/Makefile
+++ b/Makefile
@@ -103,7 +103,12 @@ run_tests: run_unit_tests # Run all tests
.PHONY: run_unit_tests
run_unit_tests: # Run unit tests
@echo -e "$(COLOR_YELLOW)Start unit tests...$(COLOR_RESET)"
- @cd src
@poetry run pytest src/tests/unit
- @cd ..
@echo -e "$(COLOR_GREEN)Unit tests passed$(COLOR_RESET)"
+
+
+.PHONY: create_superuser
+create_superuser: # Run unit tests
+ @echo -e "$(COLOR_YELLOW)Creating superuser...$(COLOR_RESET)"
+ @poetry run python src/manage.py initadmin
+ @echo -e "$(COLOR_GREEN)Superuser created$(COLOR_RESET)"
diff --git a/poetry.lock b/poetry.lock
index 28e474cb..e49940a1 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -354,6 +354,23 @@ django = ">=3.2"
[package.extras]
tests = ["coverage"]
+[[package]]
+name = "django-otp"
+version = "1.2.2"
+description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "django_otp-1.2.2-py3-none-any.whl", hash = "sha256:90765d5dac238a719f9550ac05681dd6307f513a81a10b6adb879b4abc6bc1a3"},
+ {file = "django_otp-1.2.2.tar.gz", hash = "sha256:007a6354dabb3a1a54574bf73abf045ebbde0bb8734a38e2ed7845ba450f345e"},
+]
+
+[package.dependencies]
+django = ">=3.2"
+
+[package.extras]
+qrcode = ["qrcode"]
+
[[package]]
name = "dnspython"
version = "2.4.2"
@@ -1296,4 +1313,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = "^3.11.1"
-content-hash = "03a56e6a6d84f459588eb5f27d47be1b795f02df1a398a5c91c707645e14b84a"
+content-hash = "3d36380363be3a867d55e3ba155bcdaecb575e54e8e153317f34b47e9e782aaa"
diff --git a/pyproject.toml b/pyproject.toml
index 607f51b5..fed4c03a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ pytest-django = "^4.5.2"
pytest-asyncio = "^0.21.1"
flake8-docstrings = "^1.7.0"
django-ckeditor = "^6.7.0"
+django-otp = "^1.2.2"
[tool.poetry.group.dev.dependencies]
diff --git a/requirements/develop.txt b/requirements/develop.txt
index 745fafde..bf13696f 100644
--- a/requirements/develop.txt
+++ b/requirements/develop.txt
@@ -14,6 +14,7 @@ django-asgi-lifespan==0.1.0 ; python_full_version >= "3.11.1" and python_version
django-ckeditor==6.7.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
django-environ==0.10.0 ; python_full_version >= "3.11.1" and python_version < "4"
django-js-asset==2.1.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
+django-otp==1.2.2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
django==4.2.4 ; python_full_version >= "3.11.1" and python_version < "4.0"
dnspython==2.4.2 ; python_full_version >= "3.11.1" and python_version < "4.0"
email-validator==2.0.0.post2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
diff --git a/requirements/production.txt b/requirements/production.txt
index 9be3fe6e..58c9865f 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -11,6 +11,7 @@ django-asgi-lifespan==0.1.0 ; python_full_version >= "3.11.1" and python_version
django-ckeditor==6.7.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
django-environ==0.10.0 ; python_full_version >= "3.11.1" and python_version < "4"
django-js-asset==2.1.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
+django-otp==1.2.2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
django==4.2.4 ; python_full_version >= "3.11.1" and python_version < "4.0"
dnspython==2.4.2 ; python_full_version >= "3.11.1" and python_version < "4.0"
email-validator==2.0.0.post2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0"
diff --git a/src/config/settings.py b/src/config/settings.py
index 8c4ade0c..c65ab9c0 100644
--- a/src/config/settings.py
+++ b/src/config/settings.py
@@ -25,6 +25,8 @@
"users.apps.UsersConfig",
"core.apps.CoreConfig",
"ckeditor",
+ "django_otp",
+ "django_otp.plugins.otp_email",
]
MIDDLEWARE = [
@@ -33,6 +35,7 @@
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django_otp.middleware.OTPMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -125,6 +128,9 @@
EMAIL_USE_SSL = True
DEFAULT_RECEIVER = env.str("DEFAULT_EMAIL_ADDRESS")
+OTP_EMAIL_SENDER = EMAIL_HOST_USER
+OTP_EMAIL_BODY_HTML_TEMPLATE_PATH = "emailing/otp_email.html"
+
# Telegram bot settings
TELEGRAM_TOKEN = env.str("TELEGRAM_TOKEN")
USE_REDIS_PERSISTENCE = env.bool("REDIS", default=False)
diff --git a/src/config/test_settings.py b/src/config/test_settings.py
index 6fb3667b..f801b46a 100644
--- a/src/config/test_settings.py
+++ b/src/config/test_settings.py
@@ -24,6 +24,8 @@
"bot.apps.BotConfig",
"users.apps.UsersConfig",
"core.apps.CoreConfig",
+ "django_otp",
+ "django_otp.plugins.otp_email",
]
MIDDLEWARE = [
@@ -32,6 +34,7 @@
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django_otp.middleware.OTPMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -56,7 +59,6 @@
WSGI_APPLICATION = "config.wsgi.application"
-
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
diff --git a/src/config/urls.py b/src/config/urls.py
index 53156bea..85e367f3 100644
--- a/src/config/urls.py
+++ b/src/config/urls.py
@@ -3,6 +3,10 @@
from django.contrib import admin
from django.urls import include, path
+from users.sites import CustomOTPAdminSite
+
+admin.site.__class__ = CustomOTPAdminSite
+
urlpatterns = [
path("users/", include("users.urls"), name="users"),
path("bot/", include("bot.urls"), name="bot"),
@@ -10,5 +14,3 @@
]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
-admin.site.site_header = "Бот фонда 'Расправь крылья!'"
-admin.site.site_title = "Бот фонда 'Расправь крылья!'"
diff --git a/src/users/admin.py b/src/users/admin.py
index dff58ce4..fe4a11e6 100644
--- a/src/users/admin.py
+++ b/src/users/admin.py
@@ -3,10 +3,9 @@
from django.contrib.auth.models import Group, Permission
from django.utils.translation import gettext_lazy as _
-from utils.emailing.reset_password import send_password_reset_email
-
from .forms import UserChangeForm, UserCreationForm
from .models import User
+from .utils.emailing.reset_password import send_password_reset_email
class UserAdmin(BaseUserAdmin):
diff --git a/src/users/forms.py b/src/users/forms.py
index 4edfd6f6..e15f740b 100644
--- a/src/users/forms.py
+++ b/src/users/forms.py
@@ -1,8 +1,13 @@
from django import forms
+from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import make_password
+from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext_lazy
+from django_otp.forms import OTPAuthenticationFormMixin
+from django_otp.plugins.otp_email.models import EmailDevice
from users.models import User
-from utils.users.registration import generate_random_password
+from users.utils.users.registration import generate_random_password
class UserCreationForm(forms.ModelForm):
@@ -33,3 +38,44 @@ class Meta:
"is_active",
"is_superuser",
)
+
+
+class CustomOTPAuthenticationFormMixin(OTPAuthenticationFormMixin):
+ """Customized OTPAuthenticationFormMixin mixin."""
+
+ otp_error_messages = {
+ "token_required": _("Пожалуйста, введите одноразовый код."),
+ "challenge_exception": _("Ошибка генерации кода: {0}"),
+ "not_interactive": _("Код не может быть отправлен."),
+ "challenge_message": _("Код отправлен на указанную почту."),
+ "invalid_token": _("Неверный код. Проверьте правильность."),
+ "n_failed_attempts": ngettext_lazy(
+ "Допущено %(failure_count) ошибок. Доступ временно ограничен.",
+ "Допущено %(failure_count) ошибок. Доступ временно ограничен.",
+ "failure_count",
+ ),
+ "verification_not_allowed": _("Верификация недоступна"),
+ }
+
+ def _chosen_device(self, user):
+ """Return EmailDevise as default."""
+ return EmailDevice.objects.filter(user=user).first()
+
+
+class CustomOTPAuthenticationForm(
+ CustomOTPAuthenticationFormMixin, AuthenticationForm
+):
+ """Customized OTPAuthenticationForm form."""
+
+ otp_token = forms.CharField(
+ required=False, widget=forms.TextInput(attrs={"autocomplete": "off"})
+ )
+
+ otp_challenge = forms.CharField(required=False)
+
+ def clean(self):
+ """Clean form data."""
+ self.cleaned_data = super().clean()
+ self.clean_otp(self.get_user())
+
+ return self.cleaned_data
diff --git a/src/users/signals.py b/src/users/signals.py
index e4140e92..33f79b6a 100644
--- a/src/users/signals.py
+++ b/src/users/signals.py
@@ -1,12 +1,18 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
+from django_otp.plugins.otp_email.models import EmailDevice
from users.models import User
-from utils.emailing.reset_password import send_password_reset_email
+from users.utils.emailing.reset_password import send_password_reset_email
@receiver(post_save, sender=User)
def password_reset_email(sender, instance, created, **kwargs):
"""Send email to new admin with link to set password."""
- if created and not instance.is_superuser:
- send_password_reset_email(instance)
+ if created:
+ EmailDevice.objects.create(
+ user=instance,
+ email=instance.email,
+ )
+ if not instance.is_superuser:
+ send_password_reset_email(instance)
diff --git a/src/users/sites.py b/src/users/sites.py
new file mode 100644
index 00000000..95c02d53
--- /dev/null
+++ b/src/users/sites.py
@@ -0,0 +1,14 @@
+from django_otp.admin import OTPAdminSite
+
+from users.forms import CustomOTPAuthenticationForm
+
+
+class CustomOTPAdminSite(OTPAdminSite):
+ """Customized admin site."""
+
+ login_form = CustomOTPAuthenticationForm
+
+ site_header = "Бот фонда 'Расправь крылья!'"
+ site_title = "Бот фонда 'Расправь крылья!'"
+
+ login_template = "authentication/login.html"
diff --git a/src/users/templates/authentication/login.html b/src/users/templates/authentication/login.html
new file mode 100644
index 00000000..1b911aa7
--- /dev/null
+++ b/src/users/templates/authentication/login.html
@@ -0,0 +1,95 @@
+{% extends "admin/base_site.html" %}
+{% load i18n static %}
+
+{% block extrastyle %}
+ {{ block.super }}
+
+ {{ form.media }}
+
+
+{% endblock %}
+
+{% block bodyclass %}{{ block.super }} login{% endblock %}
+
+{% block usertools %}{% endblock %}
+
+{% block nav-global %}{% endblock %}
+
+{% block content_title %}{% endblock %}
+
+{% block breadcrumbs %}{% endblock %}
+
+{% block nav-sidebar %}{% endblock %}
+
+{% block content %}
+{% if form.errors and not form.non_field_errors %}
+
+{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
+
+{% endif %}
+
+{% if form.non_field_errors %}
+{% for error in form.non_field_errors %}
+
+ {{ error }}
+
+{% endfor %}
+{% endif %}
+
+
+
+{% if user.is_authenticated %}
+
+{% blocktrans trimmed %}
+ You are authenticated as {{ username }}, but are not authorized to
+ access this page. Would you like to login to a different account?
+{% endblocktrans %}
+
+{% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/users/templates/authentication/password_set_confirm.html b/src/users/templates/authentication/password_set_confirm.html
new file mode 100644
index 00000000..a7404fb1
--- /dev/null
+++ b/src/users/templates/authentication/password_set_confirm.html
@@ -0,0 +1,97 @@
+{% extends "admin/base_site.html" %}
+{% load i18n static %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{{ form.media }}
+
+
+{% endblock %}
+
+{% block bodyclass %}{{ block.super }} login{% endblock %}
+
+{% block branding %}
+
+{% if user.is_anonymous %}
+ {% include "admin/color_theme_toggle.html" %}
+{% endif %}
+{% endblock %}
+
+{% block usertools %}{% endblock %}
+
+{% block nav-global %}{% endblock %}
+
+{% block content_title %}{% endblock %}
+
+{% block breadcrumbs %}{% endblock %}
+
+{% block nav-sidebar %}{% endblock %}
+
+{% block content %}
+
+{% if validlink %}
+
+
+
+
+ {% if user.is_authenticated %}
+
+ {% blocktrans trimmed %}
+ You are authenticated as {{ username }}, but are not
+ authorized to
+ access this page. Would you like to login to a
+ different account?
+ {% endblocktrans %}
+
+ {% endif %}
+
+ {% include "includes/form_errors.html"%}
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+ {{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% else %}
+
+
+
+
+
+
Ссылка установки пароля содержит ошибку или
+ устарела.
+
+
+
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/src/users/templates/base.html b/src/users/templates/base.html
deleted file mode 100644
index cfd92eb5..00000000
--- a/src/users/templates/base.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
- {% load static %}
-
-
-
-
-
-
-
-
- {% block title %}{% endblock %}
-
-
-Skip to main content
- {% block content %}{% endblock %}
-
-
diff --git a/src/templates/email.html b/src/users/templates/emailing/email.html
similarity index 100%
rename from src/templates/email.html
rename to src/users/templates/emailing/email.html
diff --git a/src/users/templates/emailing/otp_email.html b/src/users/templates/emailing/otp_email.html
new file mode 100644
index 00000000..a3684cfd
--- /dev/null
+++ b/src/users/templates/emailing/otp_email.html
@@ -0,0 +1,15 @@
+{% extends "emailing/email.html" %}
+{% block subject %}Одноразовый код для доступа админ-панели "Расправь Крылья!"{% endblock %}
+{% block content %}
+
+ {% if message %}
+
{{ message }}
+ {% else %}
+
Для подтверждения ваших учетных данных используйте код:
+ {% endif %}
+
+
{{ token }}
+
+
Никому его не сообщайте.
+
+{% endblock %}
diff --git a/src/templates/password_reset_email.html b/src/users/templates/emailing/password_reset_email.html
similarity index 92%
rename from src/templates/password_reset_email.html
rename to src/users/templates/emailing/password_reset_email.html
index 64666965..68a97738 100644
--- a/src/templates/password_reset_email.html
+++ b/src/users/templates/emailing/password_reset_email.html
@@ -1,4 +1,4 @@
-{% extends "email.html" %}
+{% extends "emailing/email.html" %}
{% block subject %}Доступ к админ-панели бота фонда "Расправь Крылья!"{% endblock %}
{% block content %}
diff --git a/src/users/templates/includes/form_errors.html b/src/users/templates/includes/form_errors.html
index 4688138f..93e47443 100644
--- a/src/users/templates/includes/form_errors.html
+++ b/src/users/templates/includes/form_errors.html
@@ -1,13 +1,13 @@
{% if form.errors %}
{% for field in form %}
-{% for error in field.errors %}
-
- {{ error|escape }}
-
-{% endfor %}
+ {% for error in field.errors %}
+
+ {{ error|escape }}
+
+ {% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
-
+
{{ error|escape }}
{% endfor %}
diff --git a/src/users/templates/password_set_confirm.html b/src/users/templates/password_set_confirm.html
deleted file mode 100644
index 098c1027..00000000
--- a/src/users/templates/password_set_confirm.html
+++ /dev/null
@@ -1,86 +0,0 @@
-{% extends "base.html" %}
-{% load user_filters %}
-{% block title %}Новый пароль{% endblock %}
-{% block content %}
-
-{% if validlink %}
-
-
-
-
-
-
- {% include "includes/form_errors.html"%}
-
-
-
-
-
-
-
-{% if messages %}
-
- {% for message in messages %}
- {{
- message }}
-
- {% endfor %}
-
-{% endif %}
-{% else %}
-
-
-
-
-
-
Ссылка установки пароля содержит ошибку или
- устарела.
-
-
-
-
-{% endif %}
-
-{% endblock %}
diff --git a/src/utils/emailing/__init__.py b/src/users/utils/__init__.py
similarity index 100%
rename from src/utils/emailing/__init__.py
rename to src/users/utils/__init__.py
diff --git a/src/utils/users/__init__.py b/src/users/utils/emailing/__init__.py
similarity index 100%
rename from src/utils/users/__init__.py
rename to src/users/utils/emailing/__init__.py
diff --git a/src/utils/emailing/render.py b/src/users/utils/emailing/render.py
similarity index 100%
rename from src/utils/emailing/render.py
rename to src/users/utils/emailing/render.py
diff --git a/src/utils/emailing/reset_password.py b/src/users/utils/emailing/reset_password.py
similarity index 91%
rename from src/utils/emailing/reset_password.py
rename to src/users/utils/emailing/reset_password.py
index dc79fe2a..d481cdd8 100644
--- a/src/utils/emailing/reset_password.py
+++ b/src/users/utils/emailing/reset_password.py
@@ -7,13 +7,13 @@
from config import settings
from users.models import User
-from utils.emailing.render import render_email_message
+from users.utils.emailing.render import render_email_message
def send_password_reset_email(instance: User, message=None, template=None):
"""Send email with password reset link."""
if template is None:
- template = "password_reset_email.html"
+ template = "emailing/password_reset_email.html"
reset_link = get_password_reset_link(instance)
email = render_email_message(
subject='Доступ к админ-панели бота "Расправь крылья!"',
diff --git a/src/users/utils/users/__init__.py b/src/users/utils/users/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/utils/users/registration.py b/src/users/utils/users/registration.py
similarity index 100%
rename from src/utils/users/registration.py
rename to src/users/utils/users/registration.py
diff --git a/src/users/views.py b/src/users/views.py
index 56e678ec..6bbf9183 100644
--- a/src/users/views.py
+++ b/src/users/views.py
@@ -7,12 +7,12 @@ class PasswordSetView(PasswordResetConfirmView):
"""User password reset view."""
success_url = reverse_lazy("admin:index")
- template_name = "password_set_confirm.html"
+ template_name = "authentication/password_set_confirm.html"
def form_valid(self, form):
"""Set user satus as active if password was changed."""
response = super().form_valid(form)
- messages.success(self.request, "Ваш пароль был успешно изменен.")
+ messages.success(self.request, "Пароль был успешно изменен.")
self.user.is_staff = True
self.user.save()
return response