diff --git a/.env.example b/.env.example index dbddbd0..f385296 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,17 @@ DJANGO_SECRET_KEY=secret DJANGO_DEBUG=true DJANGO_ALLOWED_HOSTS=127.0.0.1 DJANGO_INTERNAL_IPS=127.0.0.1 -ELASTIC_PASSWORD=aboba \ No newline at end of file +ALGOLIA_API_KEY=d83f046293d353948ce4a0c31ac5a10f +ALGOLIA_APPLICATION_ID=7KSTYUMEXE +CELERY_TASK_ALWAYS_EAGER=true +RABBITMQ_HOST=localhost +RABBITMQ_USER=guest +RABBITMQ_PASS=password +USER_IS_ACTIVE=true +USE_SMTP=true +EMAIL_HOST=example +EMAIL_PORT=example +EMAIL_USE_TLS=true +EMAIL_USER_SSL=false +EMAIL_HOST_USER=example +EMAIL_HOST_PASSWORD=example \ No newline at end of file diff --git a/.gitignore b/.gitignore index 68bc17f..7ea615e 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +sent_emails/ diff --git a/backend/cfehome/tests/auth/__init__.py b/backend/cfehome/api/__init__.py similarity index 100% rename from backend/cfehome/tests/auth/__init__.py rename to backend/cfehome/api/__init__.py diff --git a/backend/cfehome/api/apps.py b/backend/cfehome/api/apps.py new file mode 100644 index 0000000..f4a5357 --- /dev/null +++ b/backend/cfehome/api/apps.py @@ -0,0 +1,9 @@ +import django.apps + + +class ApiConfig(django.apps.AppConfig): + """баззовый класс прилложения api""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' + verbose_name = 'апи' diff --git a/backend/cfehome/api/urls.py b/backend/cfehome/api/urls.py new file mode 100644 index 0000000..f84e4be --- /dev/null +++ b/backend/cfehome/api/urls.py @@ -0,0 +1,7 @@ +import django.urls + + +urlpatterns = [ + django.urls.path('', django.urls.include('products.urls')), + django.urls.re_path('auth/', django.urls.include('users.urls')), +] diff --git a/backend/cfehome/cfehome/celery.py b/backend/cfehome/cfehome/celery.py new file mode 100644 index 0000000..1fcbdda --- /dev/null +++ b/backend/cfehome/cfehome/celery.py @@ -0,0 +1,11 @@ +import os + +import celery + +import django.conf + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cfehome.settings') +app = celery.Celery('cfehome', broker=django.conf.settings.CELERY_BROKER_URL) +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/backend/cfehome/cfehome/settings.py b/backend/cfehome/cfehome/settings.py index c9c7c3b..2673bfb 100644 --- a/backend/cfehome/cfehome/settings.py +++ b/backend/cfehome/cfehome/settings.py @@ -31,12 +31,13 @@ 'django.contrib.staticfiles', 'algoliasearch_django', 'rest_framework', - 'rest_framework.authtoken', 'rest_framework_simplejwt', 'corsheaders', + 'djoser', 'products.apps.ProductsConfig', 'users.apps.UsersConfig', 'search.apps.SearchConfig', + 'api.apps.ApiConfig', ] MIDDLEWARE = [ @@ -59,7 +60,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -117,6 +118,10 @@ AUTH_USER_MODEL = 'users.User' +USER_IS_ACTIVE = ( + os.getenv('USER_IS_ACTIVE', default='true').lower().strip() in YES_OPTIONS +) + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', @@ -141,3 +146,37 @@ 'https://localhost:8111', ] CORS_URLS_REGEX = r"^/api/.*$" + +CELERY_TASK_ALWAYS_EAGER = ( + os.getenv('CELERY_TASK_ALWAYS_EAGER', default='true').lower().strip() + in YES_OPTIONS +) + +RABBITMQ_HOST = os.getenv('RABBITMQ_HOST', default='localhost') +RABBITMQ_USER = os.getenv('RABBITMQ_USER', default='guest') +RABBITMQ_PASS = os.getenv('RABBITMQ_PASS', default='password') +CELERY_BROKER_URL = ( + f'amqp://{RABBITMQ_USER}:{RABBITMQ_PASS}@{RABBITMQ_HOST}:5672//' +) + +if os.getenv('USE_SMTP', default='False').lower() in YES_OPTIONS: + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + EMAIL_HOST = os.getenv('EMAIL_HOST') + EMAIL_PORT = os.getenv('EMAIL_PORT') + EMAIL_USE_TLS = ( + os.getenv('EMAIL_USE_TLS', default='true').lower() in YES_OPTIONS + ) + EMAIL_USE_SSL = ( + os.getenv('EMAIL_USER_SSL', default='false').lower() in YES_OPTIONS + ) + if EMAIL_USE_TLS: + EMAIL_USE_SSL = False + if EMAIL_USE_SSL: + EMAIL_USE_TLS = False + EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') + EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') + SERVER_EMAIL = EMAIL_HOST_USER + DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +else: + EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' + EMAIL_FILE_PATH = BASE_DIR / 'sent_emails' diff --git a/backend/cfehome/cfehome/test_settings.py b/backend/cfehome/cfehome/test_settings.py index f7c67b6..3215e33 100644 --- a/backend/cfehome/cfehome/test_settings.py +++ b/backend/cfehome/cfehome/test_settings.py @@ -24,6 +24,7 @@ 'products.apps.ProductsConfig', 'users.apps.UsersConfig', 'search.apps.SearchConfig', + 'api.apps.ApiConfig', ] MIDDLEWARE = [ @@ -42,7 +43,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -124,3 +125,10 @@ 'https://localhost:8111', ] CORS_URLS_REGEX = r"^/api/.*$" + +CELERY_TASK_ALWAYS_EAGER = True + +USER_IS_ACTIVE = True + +EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' +EMAIL_FILE_PATH = BASE_DIR / 'sent_emails' diff --git a/backend/cfehome/cfehome/urls.py b/backend/cfehome/cfehome/urls.py index b556fdd..f24ed8b 100644 --- a/backend/cfehome/cfehome/urls.py +++ b/backend/cfehome/cfehome/urls.py @@ -5,8 +5,7 @@ urlpatterns = [ django.urls.path('admin/', django.contrib.admin.site.urls), - django.urls.path('api/', django.urls.include('products.urls')), - django.urls.path('api/auth/', django.urls.include('users.urls')), + django.urls.path('api/', django.urls.include('api.urls')), ] if django.conf.settings.DEBUG: diff --git a/backend/cfehome/products/admin.py b/backend/cfehome/products/admin.py index d7f63c9..d614b55 100644 --- a/backend/cfehome/products/admin.py +++ b/backend/cfehome/products/admin.py @@ -7,6 +7,9 @@ class ProductAdmin(django.contrib.admin.ModelAdmin): """отображение модели Product в админке""" - list_display = ('id', 'title') + list_display = ( + 'id', + 'title', + ) list_display_links = ('id',) list_editable = ('title',) diff --git a/backend/cfehome/templates/users/emails/activate_user.html b/backend/cfehome/templates/users/emails/activate_user.html new file mode 100644 index 0000000..3624112 --- /dev/null +++ b/backend/cfehome/templates/users/emails/activate_user.html @@ -0,0 +1,9 @@ +{% autoescape off %} +Здравствуйте, {{ username }} +Чтобы активировать свой аккаунт, пожалуйста, пройдите по ссылке ниже: + +{{ protocol }}://{{ domain }}{% url where_to uidb64=uid token=token %} + +С уважением, +Администрация сайта bebrochka.ru +{% endautoescape %} \ No newline at end of file diff --git a/backend/cfehome/tests/conftest.py b/backend/cfehome/tests/conftest.py index 460da96..89fd313 100644 --- a/backend/cfehome/tests/conftest.py +++ b/backend/cfehome/tests/conftest.py @@ -1,9 +1,10 @@ -from tests.fixtures.auth import ( +from backend.cfehome.tests.fixtures.users import ( create_simple_user1, create_simple_user2, - create_superuser, + create_superuser ) + from tests.fixtures.products import ( create_private_product, - create_public_product, + create_public_product ) diff --git a/backend/cfehome/tests/fixtures/products.py b/backend/cfehome/tests/fixtures/products.py index 8372412..c0cdf07 100644 --- a/backend/cfehome/tests/fixtures/products.py +++ b/backend/cfehome/tests/fixtures/products.py @@ -1,8 +1,9 @@ import pytest -import tests.utils.utils import django.urls +import tests.utils.utils + @pytest.fixture def create_public_product(create_simple_user1) -> int: diff --git a/backend/cfehome/tests/fixtures/auth.py b/backend/cfehome/tests/fixtures/users.py similarity index 100% rename from backend/cfehome/tests/fixtures/auth.py rename to backend/cfehome/tests/fixtures/users.py diff --git a/backend/cfehome/tests/products/test_products.py b/backend/cfehome/tests/products/test_products.py index e29a555..be6ceb1 100644 --- a/backend/cfehome/tests/products/test_products.py +++ b/backend/cfehome/tests/products/test_products.py @@ -1,10 +1,10 @@ import pytest import rest_framework.test -import tests.utils.utils import django.urls import products.models +import tests.utils.utils @pytest.mark.django_db diff --git a/backend/cfehome/tests/users/__init__.py b/backend/cfehome/tests/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/cfehome/tests/auth/test_auth.py b/backend/cfehome/tests/users/test_auth.py similarity index 100% rename from backend/cfehome/tests/auth/test_auth.py rename to backend/cfehome/tests/users/test_auth.py diff --git a/backend/cfehome/users/admin.py b/backend/cfehome/users/admin.py index 9c10a0f..77dfb8f 100644 --- a/backend/cfehome/users/admin.py +++ b/backend/cfehome/users/admin.py @@ -7,7 +7,21 @@ class UserAdmin(django.contrib.auth.admin.UserAdmin): """отображение модели пользователя в админке""" - ... + list_display = ( + 'pk', + 'username', + 'email', + 'is_active', + 'is_superuser', + ) + list_display_links = ( + 'pk', + 'username', + ) + list_filter = ( + 'is_active', + 'is_superuser', + ) django.contrib.admin.site.register(users.models.User, UserAdmin) diff --git a/backend/cfehome/users/apps.py b/backend/cfehome/users/apps.py index 7859962..e998174 100644 --- a/backend/cfehome/users/apps.py +++ b/backend/cfehome/users/apps.py @@ -2,7 +2,7 @@ class UsersConfig(django.apps.AppConfig): - """базовый класс приложения Users""" + """базовый класс приложения Auth""" default_auto_field = 'django.db.models.BigAutoField' name = 'users' diff --git a/backend/cfehome/users/migrations/0003_alter_user_email.py b/backend/cfehome/users/migrations/0003_alter_user_email.py new file mode 100644 index 0000000..a37642d --- /dev/null +++ b/backend/cfehome/users/migrations/0003_alter_user_email.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2024-02-24 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0002_alter_user_options'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField( + max_length=254, verbose_name='email address' + ), + ), + ] diff --git a/backend/cfehome/users/models.py b/backend/cfehome/users/models.py index ddc877c..781324e 100644 --- a/backend/cfehome/users/models.py +++ b/backend/cfehome/users/models.py @@ -1,4 +1,6 @@ import django.contrib.auth.models +import django.db.models +from django.utils.translation import gettext_lazy as _ import users.managers @@ -8,6 +10,10 @@ class User(django.contrib.auth.models.AbstractUser): objects = users.managers.UserManager() + email = django.db.models.EmailField( + verbose_name=_('email address'), unique=True + ) + class Meta(django.contrib.auth.models.AbstractUser.Meta): db_table = 'auth_user' swappable = 'AUTH_USER_MODEL' diff --git a/backend/cfehome/users/permissions.py b/backend/cfehome/users/permissions.py new file mode 100644 index 0000000..ed72102 --- /dev/null +++ b/backend/cfehome/users/permissions.py @@ -0,0 +1,36 @@ +import rest_framework.permissions + +import django.http + +import users.models + + +class IsAdminOrIsSelf(rest_framework.permissions.BasePermission): + """ + проверяем возможность пользователя смотреть, + редактировать и изменять пользователя + """ + + methods = { + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + 'GET': 'view', + } + + def has_object_permission( + self, + request: django.http.HttpRequest, + view: rest_framework.views.APIView, + obj: users.models.User, + ): + user = request.user + if not user: + return False + if ( + user.pk == obj.pk + or user.is_staff + and user.has_perm(f'users.{self.methods[request.method]}_user') + ): + return True + return False diff --git a/backend/cfehome/users/serializers.py b/backend/cfehome/users/serializers.py index 84da605..be85790 100644 --- a/backend/cfehome/users/serializers.py +++ b/backend/cfehome/users/serializers.py @@ -1,8 +1,79 @@ import rest_framework.serializers +import django.conf +import django.contrib.auth.tokens +import django.contrib.sites.shortcuts + +import users.models +import users.tasks + class UserPublicSerializer(rest_framework.serializers.Serializer): """serializer для данных пользователя""" pk = rest_framework.serializers.IntegerField(read_only=True) username = rest_framework.serializers.CharField(read_only=True) + + +class UserCreateSerializer(rest_framework.serializers.ModelSerializer): + """serializer для создания пользователя""" + + password = rest_framework.serializers.CharField(write_only=True) + is_active = rest_framework.serializers.BooleanField(read_only=True) + + class Meta: + model = users.models.User + fields = [ + 'pk', + 'username', + 'password', + 'email', + 'is_active', + ] + + def create(self, validated_data: dict) -> users.models.User: + """ + создание объекта User + и отправка ему письма с подтвержением если он не активный + """ + user_obj = users.models.User.objects.create_user( + username=validated_data['username'], + password=validated_data['password'], + email=validated_data['email'], + is_active=django.conf.settings.USER_IS_ACTIVE, + ) + if not django.conf.settings.USER_IS_ACTIVE: + token_generator = ( + django.contrib.auth.tokens.default_token_generator + ) + request = self.context['request'] + users.tasks.send_email_with_token( + user_id=user_obj.pk, + template_name='users/emails/activate_user.html', + subject='Активация аккаунта', + where_to='auth:activate_user', + protocol='https' if request.is_secure() else 'http', + domain=django.contrib.sites.shortcuts.get_current_site( + request + ).domain, + token=token_generator.make_token(user_obj), + ) + return user_obj + + +class UserPasswordSerializer(rest_framework.serializers.ModelSerializer): + """serializer для пароля пользователя""" + + old_password = rest_framework.serializers.CharField(write_only=True) + + class Meta: + model = users.models.User + fields = ['old_password', 'password'] + + def validate_old_password(self, value: str) -> str: + """проверяем, правильный ли старый палоль""" + if not self.instance.check_password(value): + raise rest_framework.serializers.ValidationError( + 'old password is wrong' + ) + return value diff --git a/backend/cfehome/users/tasks.py b/backend/cfehome/users/tasks.py new file mode 100644 index 0000000..9ae9354 --- /dev/null +++ b/backend/cfehome/users/tasks.py @@ -0,0 +1,47 @@ +import celery + +import django.core.mail +import django.template.loader +import django.utils.encoding +import django.utils.http + +import users.models + + +@celery.shared_task +def send_email_with_token( + user_id: int, + template_name: str, + subject: str, + where_to: str, + domain: str, + protocol: str, + token: str, +) -> None: + """ + высылаем пользователю письмо с ссылкой + для смены пароля или активации аккаунта + token_generator - генератор токена для ссылки + template_name - шаблон для генерации текста письма + """ + user = users.models.User.objects.get(pk=user_id) + message = django.template.loader.render_to_string( + template_name, + { + 'username': user.username, + 'domain': domain, + 'uid': django.utils.http.urlsafe_base64_encode( + django.utils.encoding.force_bytes(user.pk) + ), + 'token': token, + 'protocol': protocol, + 'where_to': where_to, + }, + ) + django.core.mail.send_mail( + subject=subject, + message=message, + from_email=None, + recipient_list=[user.email], + fail_silently=False, + ) diff --git a/backend/cfehome/users/urls.py b/backend/cfehome/users/urls.py index ca850d7..d3b9559 100644 --- a/backend/cfehome/users/urls.py +++ b/backend/cfehome/users/urls.py @@ -2,6 +2,8 @@ import django.urls +import users.viewsets + app_name = 'auth' @@ -21,4 +23,21 @@ rest_framework_simplejwt.views.TokenVerifyView.as_view(), name='token_verify', ), + django.urls.path( + 'users/', + users.viewsets.UserViewSet.as_view({'post': 'create'}), + name='create_user', + ), + django.urls.path( + 'users/activate///', + users.viewsets.UserViewSet.as_view({'get': 'activate_user'}), + name='activate_user', + ), + django.urls.path( + 'users//change-password/', + users.viewsets.UserViewSet.as_view( + {'put': 'change_password', 'patch': 'change_password'} + ), + name='change_password', + ), ] diff --git a/backend/cfehome/users/viewsets.py b/backend/cfehome/users/viewsets.py new file mode 100644 index 0000000..5ec3673 --- /dev/null +++ b/backend/cfehome/users/viewsets.py @@ -0,0 +1,95 @@ +import http + +import rest_framework.decorators +import rest_framework.mixins +import rest_framework.response +import rest_framework.views +import rest_framework.viewsets + +import django.contrib.auth.tokens +import django.http +import django.utils.encoding +import django.utils.http + +import users.models +import users.permissions +import users.serializers + + +class UserViewSet( + rest_framework.mixins.CreateModelMixin, + rest_framework.viewsets.GenericViewSet, +): + """viewset для пользователя""" + + queryset = users.models.User.objects.all() + serializer_class = users.serializers.UserCreateSerializer + + def get_permissions(self) -> list: + """ + permissions в зависисмости от action + """ + permission_classes = [] + if self.action == 'change_password': + permission_classes = [users.permissions.IsAdminOrIsSelf] + return [permission() for permission in permission_classes] + + @rest_framework.decorators.action( + detail=False, + methods=['get'], + url_name='activate', + url_path='activate//', + ) + def activate_user( + self, request: django.http.HttpRequest, uidb64: str, token: str + ) -> django.http.HttpResponse: + """активация аккаунта пользователя""" + account_activated = False + try: + user = users.models.User.objects.get( + pk=django.utils.encoding.force_str( + django.utils.http.urlsafe_base64_decode(uidb64) + ) + ) + except users.models.User.DoesNotExist: + user = None + if ( + user + and django.contrib.auth.tokens.default_token_generator.check_token( + user, token + ) + ): + user.is_active = True + user.save() + account_activated = True + return rest_framework.response.Response( + {'account_activated': account_activated}, + status=( + http.HTTPStatus.OK + if account_activated + else http.HTTPStatus.NOT_FOUND + ), + ) + + @rest_framework.decorators.action( + detail=True, + methods=['put', 'patch'], + ) + def change_password( + self, request: django.http.HttpRequest, pk: int + ) -> django.http.HttpResponse: + """изменение пароля пользователя""" + user = self.get_object() + serializer = users.serializers.UserPasswordSerializer( + data=request.data, instance=user + ) + if serializer.is_valid(): + user.set_password(serializer.validated_data['password']) + user.save() + return rest_framework.response.Response( + {'status': 'password set'}, status=http.HTTPStatus.NO_CONTENT + ) + else: + return rest_framework.response.Response( + serializer.errors, status=http.HTTPStatus.BAD_REQUEST + ) diff --git a/pyproject.toml b/pyproject.toml index 3d38d92..653b8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ extend-exclude=''' [tool.isort] default_section = "THIRDPARTY" known_django = "django" -known_local_folder = ["cfehome", "products", "search", "users"] +known_local_folder = ["cfehome", "products", "search", "users", "api", "tests"] sections = ["FUTURE","STDLIB","THIRDPARTY","DJANGO","FIRSTPARTY","LOCALFOLDER"] skip = [".gitignore", "venv", "env"] skip_glob = ["*/migrations/*"] diff --git a/requirements/base.txt b/requirements/base.txt index 56cbeec..76813c0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,4 +4,5 @@ djangorestframework==3.14.0 python-dotenv==1.0.1 django_debug_toolbar==4.2.0 algoliasearch-django==3.0.0 -djangorestframework-simplejwt==5.3.1 \ No newline at end of file +djangorestframework-simplejwt==5.3.1 +celery==5.3.6 \ No newline at end of file