diff --git a/questions/migrations/0001_initial.py b/questions/migrations/0001_initial.py deleted file mode 100644 index 825efb2..0000000 --- a/questions/migrations/0001_initial.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-10 12:45 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Question', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=50)), - ('description', models.TextField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('views', models.IntegerField(default=1)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Comment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.TextField()), - ('like', models.IntegerField(default=0)), - ('is_useful', models.BooleanField(default=False)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('question', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='questions.question')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/questions/migrations/__init__.py b/questions/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/questions/migrations/__pycache__/0001_initial.cpython-38.pyc b/questions/migrations/__pycache__/0001_initial.cpython-38.pyc deleted file mode 100644 index c19465a..0000000 Binary files a/questions/migrations/__pycache__/0001_initial.cpython-38.pyc and /dev/null differ diff --git a/questions/migrations/__pycache__/__init__.cpython-38.pyc b/questions/migrations/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 6c602e8..0000000 Binary files a/questions/migrations/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c365563 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +asgiref==3.4.1 +Django==3.2.6 +django-ckeditor==6.1.0 +django-js-asset==1.2.2 +djangorestframework==3.12.4 +djangorestframework-simplejwt==4.7.2 +Pillow==8.3.1 +PyJWT==2.1.0 +pytz==2021.1 +sqlparse==0.4.1 +typing==3.7.4.3 diff --git a/stackoverflow_api/__pycache__/jwt_authorization.cpython-38.pyc b/stackoverflow_api/__pycache__/jwt_authorization.cpython-38.pyc new file mode 100644 index 0000000..53ef483 Binary files /dev/null and b/stackoverflow_api/__pycache__/jwt_authorization.cpython-38.pyc differ diff --git a/stackoverflow_api/__pycache__/settings.cpython-38.pyc b/stackoverflow_api/__pycache__/settings.cpython-38.pyc index cf15b77..c9e5e59 100644 Binary files a/stackoverflow_api/__pycache__/settings.cpython-38.pyc and b/stackoverflow_api/__pycache__/settings.cpython-38.pyc differ diff --git a/stackoverflow_api/__pycache__/urls.cpython-38.pyc b/stackoverflow_api/__pycache__/urls.cpython-38.pyc index b3689ea..a296348 100644 Binary files a/stackoverflow_api/__pycache__/urls.cpython-38.pyc and b/stackoverflow_api/__pycache__/urls.cpython-38.pyc differ diff --git a/stackoverflow_api/jwt_authorization.py b/stackoverflow_api/jwt_authorization.py new file mode 100644 index 0000000..7b5fefe --- /dev/null +++ b/stackoverflow_api/jwt_authorization.py @@ -0,0 +1,87 @@ +from django.conf import settings +from rest_framework import exceptions, authentication +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +import jwt + +from users.models import User + + +class JWTAuthentication(BaseAuthentication): + authentication_header_prefix = 'Bearer' + + def authenticate(self, request): + """ + Метод authenticate вызывается каждый раз, независимо от того, требует + ли того эндпоинт аутентификации. 'authenticate' имеет два возможных + возвращаемых значения: + 1) None - мы возвращаем None если не хотим аутентифицироваться. + Обычно это означает, что мы значем, что аутентификация не удастся. + Примером этого является, например, случай, когда токен не включен в + заголовок. + 2) (user, token) - мы возвращаем комбинацию пользователь/токен + тогда, когда аутентификация пройдена успешно. Если ни один из + случаев не соблюден, это означает, что произошла ошибка, и мы + ничего не возвращаем. В таком случае мы просто вызовем исключение + AuthenticationFailed и позволим DRF сделать все остальное. + """ + request.user = None + + # 'auth_header' должен быть массивом с двумя элементами: + # 1) именем заголовка аутентификации (Token в нашем случае) + # 2) сам JWT, по которому мы должны пройти аутентифкацию + auth_header = authentication.get_authorization_header(request).split() + auth_header_prefix = self.authentication_header_prefix.lower() + + if not auth_header: + return None + + if len(auth_header) == 1: + # Некорректный заголовок токена, в заголовке передан один элемент + return None + + elif len(auth_header) > 2: + # Некорректный заголовок токена, какие-то лишние пробельные символы + return None + + # JWT библиотека которую мы используем, обычно некорректно обрабатывает + # тип bytes, который обычно используется стандартными библиотеками + # Python3 (HINT: использовать PyJWT). Чтобы точно решить это, нам нужно + # декодировать prefix и token. Это не самый чистый код, но это хорошее + # решение, потому что возможна ошибка, не сделай мы этого. + prefix = auth_header[0].decode('utf-8') + token = auth_header[1].decode('utf-8') + + if prefix.lower() != auth_header_prefix: + # Префикс заголовка не тот, который мы ожидали - отказ. + return None + + # К настоящему моменту есть "шанс", что аутентификация пройдет успешно. + # Мы делегируем фактическую аутентификацию учетных данных методу ниже. + return self._authenticate_credentials(request, token) + + def _authenticate_credentials(self, request, token): + """ + Попытка аутентификации с предоставленными данными. Если успешно - + вернуть пользователя и токен, иначе - сгенерировать исключение. + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY) + except Exception: + msg = 'Ошибка аутентификации. Невозможно декодировать токеню' + raise exceptions.AuthenticationFailed(msg) + + try: + user = User.objects.get(pk=payload['id']) + except User.DoesNotExist: + msg = 'Пользователь соответствующий данному токену не найден.' + raise exceptions.AuthenticationFailed(msg) + + if not user.is_active: + msg = 'Данный пользователь деактивирован.' + raise exceptions.AuthenticationFailed(msg) + + return (user, token) + + + diff --git a/stackoverflow_api/settings.py b/stackoverflow_api/settings.py index 7582658..7d2f846 100644 --- a/stackoverflow_api/settings.py +++ b/stackoverflow_api/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ import os +from datetime import timedelta from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -25,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*'] AUTH_USER_MODEL = 'users.User' @@ -35,6 +36,9 @@ 'ckeditor', 'questions.apps.QuestionsConfig', 'users', + 'rest_framework', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -86,23 +90,29 @@ } } +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSESS': [ + 'custom_authorization.CustomAuthorization', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], +} # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.users.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.users.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.users.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.users.password_validation.NumericPasswordValidator', - }, + # { + # 'NAME': 'django.contrib.users.password_validation.UserAttributeSimilarityValidator', + # }, + # { + # 'NAME': 'django.contrib.users.password_validation.MinimumLengthValidator', + # }, + # { + # 'NAME': 'django.contrib.users.password_validation.CommonPasswordValidator', + # }, + # { + # 'NAME': 'django.contrib.users.password_validation.NumericPasswordValidator', + # }, ] @@ -111,7 +121,7 @@ LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +TIME_ZONE = 'Europe/Moscow' USE_I18N = True @@ -123,13 +133,61 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ -STATIC_URL = '/static/' -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'static'), -] +if DEBUG: + STATIC_URL = '/static/' + MEDIA_URL = '/media/' + + STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static'), + ] + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +else: + STATIC_ROOT = '/var/www/static/' + + STATICFILES_DIRS = [ + Path(BASE_DIR, '/var/www/static/') + ] + MEDIA_URL = '/media/' + MEDIA_ROOT = Path(BASE_DIR, '/var/www/media/') + +DEFAULT_USER_PROFILE_LOGO = '/media/users/default/logo.png' +CUSTOM_USER_PROFILE_LOGO = 'media/default/{}/logo/' +GITHUB_LINK_PATTERN = 'https://github.com/{}' +TWITTER_LINK_PATTERN = 'https://twitter.com/{}' # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# SIMPLE JWT SETTINGS +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=5), ## вставляется в exp при генерации токенов + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, # при рефрешу токенов выдает новую пару + 'BLACKLIST_AFTER_ROTATION': True, # для этого используется специальноое приложение blacklist app. ('rest_framework_simplejwt.token_blacklist' в installed_apps) + 'UPDATE_LAST_LOGIN': True, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + 'JWK_URL': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), +} diff --git a/stackoverflow_api/urls.py b/stackoverflow_api/urls.py index 5a15d96..12c5445 100644 --- a/stackoverflow_api/urls.py +++ b/stackoverflow_api/urls.py @@ -21,5 +21,5 @@ urlpatterns = [ path('admin/', admin.site.urls), path('admin-questions/', question_admin_site.urls), - path('', include('questions.urls')) + path('', include('users.urls')) ] diff --git a/users/__pycache__/models.cpython-38.pyc b/users/__pycache__/models.cpython-38.pyc index d48c8f2..4cf8173 100644 Binary files a/users/__pycache__/models.cpython-38.pyc and b/users/__pycache__/models.cpython-38.pyc differ diff --git a/users/__pycache__/permissions.cpython-38.pyc b/users/__pycache__/permissions.cpython-38.pyc new file mode 100644 index 0000000..b4b213f Binary files /dev/null and b/users/__pycache__/permissions.cpython-38.pyc differ diff --git a/users/__pycache__/serializers.cpython-38.pyc b/users/__pycache__/serializers.cpython-38.pyc new file mode 100644 index 0000000..2846bbb Binary files /dev/null and b/users/__pycache__/serializers.cpython-38.pyc differ diff --git a/users/__pycache__/urls.cpython-38.pyc b/users/__pycache__/urls.cpython-38.pyc new file mode 100644 index 0000000..6bc166a Binary files /dev/null and b/users/__pycache__/urls.cpython-38.pyc differ diff --git a/users/__pycache__/views.cpython-38.pyc b/users/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000..7184f0e Binary files /dev/null and b/users/__pycache__/views.cpython-38.pyc differ diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 355b468..488560a 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 3.2.5 on 2021-08-10 12:44 +# Generated by Django 3.2.6 on 2021-08-17 15:56 -import django.contrib.auth.models -import django.contrib.auth.validators from django.db import migrations, models -import django.utils.timezone +import django.db.models.deletion +import django.db.models.manager +import uuid class Migration(migrations.Migration): @@ -18,28 +18,47 @@ class Migration(migrations.Migration): 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')), - ('login', models.CharField(blank=True, max_length=150)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('username', models.CharField(db_index=True, max_length=255, unique=True)), + ('email', models.EmailField(db_index=True, max_length=254, unique=True)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('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, + 'unique_together': {('username', 'email')}, }, + ), + migrations.CreateModel( + name='UserProfileContactInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('website_link', models.URLField(blank=True, null=True)), + ('github_link', models.URLField(blank=True, null=True)), + ('twitter_link', models.URLField(blank=True, null=True)), + ('website_link_active', models.BooleanField(default=False)), + ('github_link_active', models.BooleanField(default=False)), + ('twitter_link_active', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='users.user')), + ('logo', models.ImageField(default='/media/users/default/logo.png', upload_to='media/default//logo/')), + ('location', models.CharField(blank=True, max_length=200, null=True)), + ('about', models.TextField(blank=True, max_length=255, null=True)), + ('title', models.CharField(blank=True, max_length=100, null=True)), + ('user_profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='users.userprofilecontactinfo')), + ], managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ('test', django.db.models.manager.Manager()), ], ), ] diff --git a/users/migrations/0002_rename_user_profile_userprofile_contacts.py b/users/migrations/0002_rename_user_profile_userprofile_contacts.py new file mode 100644 index 0000000..1b4923c --- /dev/null +++ b/users/migrations/0002_rename_user_profile_userprofile_contacts.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.6 on 2021-08-17 16:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='userprofile', + old_name='user_profile', + new_name='contacts', + ), + ] diff --git a/users/migrations/__pycache__/0001_initial.cpython-38.pyc b/users/migrations/__pycache__/0001_initial.cpython-38.pyc index f694720..e3115e6 100644 Binary files a/users/migrations/__pycache__/0001_initial.cpython-38.pyc and b/users/migrations/__pycache__/0001_initial.cpython-38.pyc differ diff --git a/users/migrations/__pycache__/0002_rename_user_profile_userprofile_contacts.cpython-38.pyc b/users/migrations/__pycache__/0002_rename_user_profile_userprofile_contacts.cpython-38.pyc new file mode 100644 index 0000000..165254f Binary files /dev/null and b/users/migrations/__pycache__/0002_rename_user_profile_userprofile_contacts.cpython-38.pyc differ diff --git a/users/migrations/__pycache__/__init__.cpython-38.pyc b/users/migrations/__pycache__/__init__.cpython-38.pyc index 2c6608e..9b2bff9 100644 Binary files a/users/migrations/__pycache__/__init__.cpython-38.pyc and b/users/migrations/__pycache__/__init__.cpython-38.pyc differ diff --git a/users/models.py b/users/models.py index dd4e152..507d205 100644 --- a/users/models.py +++ b/users/models.py @@ -1,9 +1,185 @@ -from django.contrib.auth.models import AbstractUser +import uuid +from datetime import datetime, timedelta + +from django.contrib.auth.base_user import BaseUserManager, AbstractBaseUser +from django.contrib.auth.models import AbstractUser, PermissionsMixin from django.db import models # Create your models here. +from django.db.models import Manager +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + +from stackoverflow_api import settings + + +class UserManager(BaseUserManager): + """ + Django требует, чтобы кастомные пользователи определяли свой собственный + класс Manager. Унаследовавшись от BaseUserManager, мы получаем много того + же самого кода, который Django использовал для создания User (для демонстрации). + """ + + def create_user(self, username, email, password=None): + """ Создает и возвращает пользователя с имэйлом, паролем и именем. """ + if username is None: + raise TypeError('Users must have a username.') + # + if email is None: + raise TypeError('Users must have an email address.') + + user = self.model(username=username, email=self.normalize_email(email)) + user.set_password(password) + user.save() + print(user.pk) + self._create_user_profile(user) + return user + + def create_superuser(self, username, email, password): + """ Создает и возввращет пользователя с привилегиями суперадмина. """ + if password is None: + raise TypeError('Superusers must have a password.') + user = self.create_user(username, email, password) + user.is_superuser = True + user.is_staff = True + user.save() + + return user + + def get(self, *args, **kwargs): + try: + user = super().get(*args, **kwargs) + return user + except User.DoesNotExist: + return None + + +class User(AbstractBaseUser, PermissionsMixin): + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # Каждому пользователю нужен понятный человеку уникальный идентификатор, + # который мы можем использовать для предоставления User в пользовательском + # интерфейсе. Мы так же проиндексируем этот столбец в базе данных для + # повышения скорости поиска в дальнейшем. + username = models.CharField(db_index=True, max_length=255, unique=True) + + # Так же мы нуждаемся в поле, с помощью которого будем иметь возможность + # связаться с пользователем и идентифицировать его при входе в систему. + # Поскольку адрес почты нам нужен в любом случае, мы также будем + # использовать его для входы в систему, так как это наиболее + # распространенная форма учетных данных на данный момент (ну еще телефон). + email = models.EmailField(db_index=True, unique=True) + + # Когда пользователь более не желает пользоваться нашей системой, он может + # захотеть удалить свой аккаунт. Для нас это проблема, так как собираемые + # нами данные очень ценны, и мы не хотим их удалять :) Мы просто предложим + # пользователям способ деактивировать учетку вместо ее полного удаления. + # Таким образом, они не будут отображаться на сайте, но мы все еще сможем + # далее анализировать информацию. + is_active = models.BooleanField(default=True) + + # Этот флаг определяет, кто может войти в административную часть нашего + # сайта. Для большинства пользователей это флаг будет ложным. + is_staff = models.BooleanField(default=False) + + # Временная метка создания объекта. + created_at = models.DateTimeField(auto_now_add=True) + + # Временная метка показывающая время последнего обновления объекта. + updated_at = models.DateTimeField(auto_now=True) + + # Дополнительный поля, необходимые Django + # при указании кастомной модели пользователя. + + # Свойство USERNAME_FIELD сообщает нам, какое поле мы будем использовать + # для входа в систему. + USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['email', 'password'] + + # Сообщает Django, что определенный выше класс UserManager + # должен управлять объектами этого типа. + objects = UserManager() + + def __str__(self): + """ Строковое представление модели (отображается в консоли) """ + return self.username + + def get_full_name(self): + """ + Этот метод требуется Django для таких вещей, как обработка электронной + почты. Обычно это имя фамилия пользователя, но поскольку мы не + используем их, будем возвращать username. + """ + return self.username + + def get_short_name(self): + """ Аналогично методу get_full_name(). """ + return self.username + + def _create_user_profile(self): + """ Создает и инициализирует профиль юзера вместе с контактной информацией""" + print(self) + user_profile = UserProfile() + user_profile.user = self + user_contact_info = UserProfileContactInfo() + user_profile.contacts = user_contact_info + user_contact_info.save() + user_profile.save() + + def save(self, *args, **kwargs): + """ Переопределил данный метод для включения логики по созданию дефолтного юзерпрофайл для пользователя""" + super().save(*args, **kwargs) + self._create_user_profile() + + class Meta: + unique_together = ('username', 'email', ) + +class UserProfileManager(models.Manager): + + def get_queryset(self): + return super().get_queryset().select_related('userprofilecontactinfo__user_profile') + +# TODO 1. Сделать уникальными все линки и добавить транзакцию в создание юзера +""" отдельная таблица нужна для того, чтобы потом создать отдельный сервис который будет проверять все это""" +class UserProfileContactInfo(models.Model): + """ Для того, чтобы сделать select_related при запросе на профиль""" + + website_link = models.URLField(blank=True, null=True) + github_link = models.URLField(blank=True, null=True) + twitter_link = models.URLField(blank=True, null=True) + + website_link_active = models.BooleanField(default=False) + github_link_active = models.BooleanField(default=False) + twitter_link_active = models.BooleanField(default=False) + + +class UserProfile(models.Model): + user = models.OneToOneField(User, primary_key=True, to_field='id', on_delete=models.CASCADE) + contacts = models.OneToOneField(UserProfileContactInfo, blank=False, + on_delete=models.CASCADE) + logo = models.ImageField(blank=False, null=False, upload_to=settings.CUSTOM_USER_PROFILE_LOGO.format(id), + default=settings.DEFAULT_USER_PROFILE_LOGO) + location = models.CharField(blank=True, null=True, max_length=200) + about = models.TextField(blank=True, null=True, max_length=255) + title = models.CharField(blank=True, null=True, max_length=100) + + test = UserProfileManager() + objects = Manager() + + + + + + + + + + + + + + + + -class User(AbstractUser): - login = models.CharField(max_length=150, blank=True, unique=False) diff --git a/users/permissions.py b/users/permissions.py new file mode 100644 index 0000000..dd3568f --- /dev/null +++ b/users/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + + +class IsResourceOwner(permissions.BasePermission): + + def has_object_permission(self, request, view, obj): + return bool( + request.method in permissions.SAFE_METHODS + or request.user == obj.user + ) diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..22bdf0d --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator + +from users.models import UserProfile, UserProfileContactInfo + +User = get_user_model() + + +class CreateUserSerializer(serializers.ModelSerializer): + # username = serializers.CharField(validators=[UniqueValidator(queryset=User.objects.all())]) + + class Meta: + model = User + fields = ('username', 'password', 'email') + # extra_kwargs = {'email': {'required': True}} + # validators = [ + # UniqueTogetherValidator( + # queryset=User.objects.all(), + # fields=('username', 'email') + # ) + # ] + + + def validate_password(self, value: str) -> str: + """ + Hash value passed by user. + + :param value: password of a user + :return: a hashed version of the password + """ + return make_password(value) + + # def create(self, validated_data): + # return super().create(validated_data) + + # def create(self, validated_data): + # print(validated_data) + # return User.objects.create_user(**validated_data) + + # def validate(self, data): + # print(data) + # return data +# +# class MyTokenObtainPairSerializer(TokenObtainPairSerializer): +# @classmethod +# def get_token(cls, user): +# token = super().get_token(user) +# # Add custom claims +# token['username'] = user.name +# # ... +# print(token) +# return token + + +class UserProfileContactInfoSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfileContactInfo + fields = ['website_link', 'twitter_link', 'github_link'] + + +class UserProfileSerializer(serializers.ModelSerializer): + contacts = UserProfileContactInfoSerializer(many=False, read_only=True) # так мы добавляем сериализованный обьект в другой обьект + + class Meta: + model = UserProfile + fields = ('logo', 'location', 'about', 'title', 'contacts') + + def create(self, validated_data): + print(validated_data) + return super().create(validated_data) + + + diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..79e03d0 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from .views import GetUserAPIView, UserCreateAPIView, UserProfileDetailAPIView, TestClass + +urlpatterns = [ + path('hello/', GetUserAPIView.as_view(), name='hello'), + path('auth/', TokenObtainPairView.as_view(), name='auth'), + path('refresh/', TokenRefreshView.as_view(), name='auth'), + path('register/', UserCreateAPIView.as_view(), name='reg'), + path('/profile/', UserProfileDetailAPIView.as_view(), name='profile'), + path('test///', TestClass.as_view()), +] \ No newline at end of file diff --git a/users/utils.py b/users/utils.py new file mode 100644 index 0000000..b444a14 --- /dev/null +++ b/users/utils.py @@ -0,0 +1,11 @@ + +from rest_framework.views import exception_handler + +def user_exception_handler(exc, context): + # Call REST framework's default exception handler first, + # to get the standard error response. + response = exception_handler(exc, context) + + if response is not None: + response.data['status_code'] = response.status_code + return response diff --git a/users/views.py b/users/views.py index 91ea44a..db54b5c 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,88 @@ -from django.shortcuts import render - +from django.contrib.auth import get_user_model +from rest_framework import generics, status, permissions # Create your views here. +from rest_framework.generics import get_object_or_404 + +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.authentication import JWTAuthentication + +from users.models import UserProfile +from users.permissions import IsResourceOwner +from users.serializers import CreateUserSerializer, UserProfileSerializer + +User = get_user_model() + +class GetUserAPIView(generics.RetrieveAPIView): + permission_classes = (permissions.IsAuthenticated, ) + authentication_classes = (JWTAuthentication, ) + + def get(self, request, *args, **kwargs): + return Response('1') + +## чтобы уж совсем не быть ленивым, я напишу свою регистрацию + +class UserCreateAPIView(generics.CreateAPIView): + permission_classes = (permissions.AllowAny, ) + model = get_user_model() + serializer_class = CreateUserSerializer + + ## этого можно было и не делать, но чисто для себя я написал + def post(self, request, *args, **kwargs): + print(request.user) + user = request.data + serializer = self.serializer_class(data=user) + if serializer.is_valid(raise_exception=True): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class TestClass(generics.RetrieveAPIView): + multiple_lookup_fields = ('username', 'email', ) + # пример как сделать поиск по нескольким лукапам + # данный метод вызывается тогда, когда достается обьект по лукапам + # тут недоделанный вариант, пока нет в этом необходимости + def get_object(self): + queryset = self.get_queryset() + filter = {} + for field in self.multiple_lookup_fields: + filter[field] = self.kwargs[field] + obj = get_object_or_404(queryset, **filter) + self.check_object_permissions(self.request, obj) + return obj + +class UserProfileDetailAPIView(generics.RetrieveUpdateAPIView): + queryset = UserProfile.objects.all() + lookup_field = 'username' + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsResourceOwner) ## данный пермиссионс требует авторизации только в тех методах запросов, которые изменяют данные + authentication_classes = (JWTAuthentication, ) + serializer_class = UserProfileSerializer + detail = {'detail': 'User with that id doesn\'t exist'} + + def get(self, request, *args, **kwargs): + data = self.get_object() + serializer = self.serializer_class(data) + return Response(serializer.data, status=status.HTTP_200_OK) + + # def patch(self, request, *args, **kwargs): + # return super().patch(request, *args, **kwargs) + + def get_object(self) -> UserProfile: + user = User.objects.get(username=self.kwargs.get(self.lookup_field)) ## вот так достаем из lookup + obj = get_object_or_404(self.get_queryset(), user=user) + self.check_object_permissions(self.request, obj) + return obj + + + + + + + + + + + + + +