diff --git a/src/account/admin.py b/src/account/admin.py index 9f59b5d..cdd4aa9 100644 --- a/src/account/admin.py +++ b/src/account/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from .models import UsedResetToken, User, Profile, NewsletterSubscriber +from .models import UsedResetToken, User, Profile from .forms import UserRegisterForm @@ -13,37 +13,34 @@ class UserAdmin(BaseUserAdmin): # The fields to be used in displaying the User model. # These override the definitions on the base UserAdmin # that reference specific fields on auth.User. - list_display=('email', 'username', 'active',) - list_filter = ('active','staff','admin',) - search_fields=['email'] + list_display = ('email', 'username', 'active',) + list_filter = ('active', 'staff', 'admin',) + search_fields = ['email'] fieldsets = ( ('User', {'fields': ('email', 'password')}), - ('Permissions', {'fields': ('admin','staff','active','verified_email',)}), + ('Permissions', { + 'fields': ('admin', 'staff', 'active', 'verified_email',)}), ) + # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin # overrides get_fieldsets to use this attribute when creating a user. add_fieldsets = ( (None, { - 'classes': ('wide',), - 'fields': ("email","username","password","password2",) - } + 'classes': ('wide',), + 'fields': ("email", "username", "password", "password2", "fullname") + } ), ) ordering = ('email',) filter_horizontal = () - - @admin.register(Profile) class ProfileAdmin(admin.ModelAdmin): - list_display = ('fullname', 'username', 'account_type', 'approved', 'phone',) - search_fields = ('fullname', 'address', 'state', 'city','zip',) - list_filter = ('account_type', 'approved', 'state',) - ordering = ('-created',) - - + list_display = ('fullname', 'personality', 'user') + search_fields = ('fullname',) + list_filter = ('personality', 'skills', 'interest',) admin.site.register(User, UserAdmin) -admin.site.register([NewsletterSubscriber, UsedResetToken]) \ No newline at end of file +admin.site.register([UsedResetToken]) diff --git a/src/account/api/base/permissions.py b/src/account/api/base/permissions.py deleted file mode 100644 index 4242529..0000000 --- a/src/account/api/base/permissions.py +++ /dev/null @@ -1,57 +0,0 @@ -from rest_framework.permissions import BasePermission, IsAuthenticated - -from project_api_key.permissions import has_staff_key -from utils.base.logger import err_logger, logger # noqa - - -class IsAuthenticatedAdmin(BasePermission): - def has_permission(self, request, view): - # Get the user, if the user is staff or admin (open access) - try: - if request.user.is_authenticated: - user = request.user - if user.staff or user.admin: - return True - except Exception as e: - err_logger.exception(e) - - -# Create instance for other permissisions to user -is_auth_admin = IsAuthenticatedAdmin() -is_auth_normal = IsAuthenticated() - - -class PermA(BasePermission): - """ - Permission to check if user uses a staff project - api key or is an authenticated admin - """ - - def has_permission(self, request, view): - # Check if the user has staff project api key - if has_staff_key.has_permission(request, view): - return True - - # Check if the user is an authenticated admin - if is_auth_admin.has_permission(request, view): - return True - - -class PermB(BasePermission): - """ - Permissions to check if user has a project - staff api key and is authenticated - Or user is authenticated admin - """ - - def has_permission(self, request, view): - # Check if the user has project api key - if has_staff_key.has_permission(request, view): - - # Check if the user is authenticated - if is_auth_normal.has_permission(request, view): - return True - - # Check if the user is an authenticated admin - if is_auth_admin.has_permission(request, view): - return True diff --git a/src/account/api/base/serializers.py b/src/account/api/base/serializers.py index 451165f..cfce48f 100644 --- a/src/account/api/base/serializers.py +++ b/src/account/api/base/serializers.py @@ -1,9 +1,9 @@ from django.conf import settings from django.contrib.auth.password_validation import validate_password from rest_framework import serializers +from django.contrib.auth.models import AbstractBaseUser from account.models import Profile, User -from utils.base.validators import validate_special_char class JWTTokenValidateSerializer(serializers.Serializer): @@ -27,38 +27,51 @@ class TokenGenerateSerializerEmail(serializers.Serializer): help_text='Email of user to verify and return tokens for') -class TokenGenerateResponseSerializer(serializers.Serializer): - uidb64 = serializers.CharField() - token = serializers.CharField() - - class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField( write_only=True, required=True, validators=[validate_password]) - first_name = serializers.CharField( - required=True, help_text='User first name', - validators=[validate_special_char], max_length=30) - last_name = serializers.CharField( - required=True, help_text='User last name', - validators=[validate_special_char], max_length=30) + username = serializers.CharField(required=True) class Meta: model = User - fields = ('password', 'email', 'first_name', 'last_name') + fields = ('password', 'email', 'username') def create(self, validated_data): email = validated_data.get('email') password = validated_data.get('password') - user = User.objects.create_user(email=email, password=password) + username = validated_data.get('username') + user = User.objects\ + .create_user(email=email, password=password, username=username) + return user - # Get the profile and update the first and last names - profile = user.profile - profile.first_name = validated_data.get('first_name') - profile.last_name = validated_data.get('last_name') - profile.save() +class ValidateOtpSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + otp = serializers.CharField(required=True) + + def validate_email(self, value): + try: + user = User.objects.get(email=value) + except User.DoesNotExist: + raise serializers.ValidationError('User does not exist') return user + def validate(self, attrs): + otp = attrs['otp'] + user: User = attrs['email'] + + if not user.validate_otp(otp): + raise serializers.ValidationError('Invalid OTP') + + return attrs + + +class ValidateRegistrationOtpSerializer(ValidateOtpSerializer): + def save(self, **kwargs): + user: User = self.validated_data['email'] + user.verified_email = True + user.save() + class LoginSerializer(serializers.Serializer): email = serializers.EmailField(required=True) @@ -98,10 +111,11 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = [ + fields = ( 'email', + 'username', 'profile' - ] + ) def validate_phoneno(self, value): if value: @@ -119,66 +133,56 @@ def validate_phoneno(self, value): return value -class ForgetChangePasswordSerializerSwagger(serializers.Serializer): - new_password = serializers.CharField( - write_only=True, required=True, validators=[validate_password]) - confirm_password = serializers.CharField(write_only=True, required=True) - - -class ForgetChangePasswordSerializer(serializers.ModelSerializer): - profile = ProfileSerializer(read_only=True) - new_password = serializers.CharField( +class ForgetPasswordSerializer(serializers.Serializer): + uidb64 = serializers.CharField(required=True) + token = serializers.CharField(required=True) + password = serializers.CharField( write_only=True, required=True, validators=[validate_password]) - confirm_password = serializers.CharField(write_only=True, required=True) - - class Meta: - model = User - fields = ( - 'id', 'email', 'new_password', - 'confirm_password', 'profile',) - extra_kwargs = { - 'email': {'read_only': True}, - } def validate(self, attrs): - # Validate if the provided passwords are similar - new_password = attrs.get('new_password') - confirm_password = attrs.get('confirm_password') + # Validate the uidb64 and token + return attrs - if not new_password: - raise serializers.ValidationError( - {"new_password": "New password field is required."}) + def save(self, **kwargs): + # Get the user + user: User = self.validated_data['user'] + password = self.validated_data['password'] - if not confirm_password: - raise serializers.ValidationError( - {"confirm_password": "Confirm password field is required."}) + # Set password + user.set_password(password) + user.save() - if new_password != confirm_password: - raise serializers.ValidationError( - {"new_password": "Password fields didn't match."}) + return user - return attrs - def update(self, instance, validated_data): - # Set password - new_password = validated_data.get('new_password') - instance.set_password(new_password) - instance.save() +class RequestForgetPasswordSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) - return instance + # Store the user instance + user: AbstractBaseUser = None + + def validate_email(self, value): + try: + self.user: User = User.objects.get(email=value) + if not self.user.verified_email: + raise serializers.ValidationError( + 'Please verify your email first') + except User.DoesNotExist: + raise serializers.ValidationError('User does not exist') + return value class ChangePasswordSerializer(serializers.ModelSerializer): old_password = serializers.CharField(write_only=True, required=True) new_password = serializers.CharField( write_only=True, required=True, validators=[validate_password]) - confirm_password = serializers.CharField(write_only=True, required=True) + instance: AbstractBaseUser = None class Meta: model = User fields = ( 'id', 'email', 'old_password', - 'new_password', 'confirm_password',) + 'new_password',) extra_kwargs = { 'email': {'read_only': True}, } @@ -187,12 +191,6 @@ def validate(self, attrs): if not self.instance.check_password(attrs['old_password']): raise serializers.ValidationError( {'old_password': 'Old password is not correct'}) - - # Validate if the provided passwords are similar - if attrs['new_password'] != attrs['confirm_password']: - raise serializers.ValidationError( - {"new_password": "Password fields didn't match."}) - return attrs def update(self, instance, validated_data): @@ -204,18 +202,6 @@ def update(self, instance, validated_data): return instance -class RegisterResponseSerializer(serializers.Serializer): - user = UserSerializer() - token = TokenGenerateResponseSerializer() - - -class LoginResponseSerializer431(serializers.Serializer): - token = TokenGenerateResponseSerializer() - fullname = serializers.CharField( - help_text='Fullname of token\'s user generated') - email = serializers.CharField(help_text='Email of token\'s user generated') - - class LoginResponseSerializer200(serializers.Serializer): user = UserSerializer() tokens = JWTTokenResponseSerializer() diff --git a/src/account/api/base/tokens.py b/src/account/api/base/tokens.py deleted file mode 100644 index 3e01cee..0000000 --- a/src/account/api/base/tokens.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.contrib.auth.tokens import PasswordResetTokenGenerator -import six - - -class EmailConfirmationToken(PasswordResetTokenGenerator): - def _make_hash_value(self, user, timestamp): - return ( - six.text_type( - user.pk - ) + six.text_type( - timestamp - ) + six.text_type( - user.email - ) - ) - - -account_confirm_token = EmailConfirmationToken() diff --git a/src/account/api/base/urls.py b/src/account/api/base/urls.py index f90313d..09d9060 100644 --- a/src/account/api/base/urls.py +++ b/src/account/api/base/urls.py @@ -1,28 +1,30 @@ from django.urls import path + from . import views app_name = 'auth' urlpatterns = [ - path('register/', views.RegisterAPIView.as_view(), name='register'), path('login/', views.LoginAPIView.as_view(), name='login'), - path('forget-password//', - views.ForgetPasswordView.as_view(), name='forget_password'), - - # Tokens + path('register/', views.RegisterAPIView.as_view(), name='register'), + path( + 'validate-otp/', + views.ValidateRegistrationOtpView.as_view(), name='validate_otp'), + path( + 'user/retrieve-update/', + views.UserRetrieveUpdateAPIView.as_view(), + name='user_retrieve_update'), + path( + 'request-password-reset/', + views.RequestForgetPasswordView.as_view(), + name='request_password_reset'), + path( + 'password-reset/', + views.ForgetPasswordView.as_view(), + name='password_reset'), path('token/user/refresh/', views.TokenRefreshAPIView.as_view(), name='token_refresh'), path('token/user/validate/', views.TokenVerifyAPIView.as_view(), name='token_validate'), - - # Path for changing user password - path('user/forgetPassword/', views.ForgetChangePasswordView.as_view(), - name='forget_password_change'), - path('user/changePassword/', views.ChangePasswordView.as_view(), + path('user/change-password/', views.ChangePasswordView.as_view(), name='change_password'), - path('user/profile/update/', - views.ProfileAPIView.as_view(), name='detail_profile'), - - # Paths for getting and finding user informations - path('users/', views.UserListView.as_view(), name='user_list'), - path('users/detail/', views.UserAPIView.as_view(), name='user_data'), ] diff --git a/src/account/api/base/utils.py b/src/account/api/base/utils.py new file mode 100644 index 0000000..9849827 --- /dev/null +++ b/src/account/api/base/utils.py @@ -0,0 +1,35 @@ + +from account.models import User +from utils.base.email import render_email_message + + +def send_otp_email(user: User, request, template: str, subject: str): + """ + Sends a otp email to user. + """ + + otp = user.set_otp() + message = render_email_message( + request, template, {"otp": otp} + ) + return user.email_user(subject, message) + + +def send_verification_email(user: User, request): + """ + Sends a verification email to user. + """ + + return send_otp_email( + user, request, + 'account/email/verify.html', 'Verify your email') + + +def send_password_reset_email(user: User, request): + """ + Sends a password reset email to user. + """ + + return send_otp_email( + user, request, + 'account/email/password_reset.html', 'Reset your password') diff --git a/src/account/api/base/views.py b/src/account/api/base/views.py index e00aca0..cb96325 100644 --- a/src/account/api/base/views.py +++ b/src/account/api/base/views.py @@ -1,6 +1,3 @@ -from django.shortcuts import get_object_or_404 -from django.utils.encoding import force_bytes, force_str -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.response import Response @@ -9,12 +6,80 @@ from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from rest_framework_simplejwt.serializers import TokenRefreshSerializer -from account.models import Profile, User +from account.models import User from utils.base.general import get_tokens_for_user from . import serializers -from .permissions import PermA, PermB -from .tokens import account_confirm_token +from .utils import send_password_reset_email, send_verification_email + + +class RegisterAPIView(generics.CreateAPIView): + """ + Register a new user and + send a verification email to user. + """ + permission_classes = [] + serializer_class = serializers.RegisterSerializer + + @swagger_auto_schema( + responses={201: serializers.UserSerializer} + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + user_serializer = serializers.UserSerializer(user) + send_verification_email(user, request) + return Response( + data=user_serializer.data, + status=status.HTTP_201_CREATED + ) + + +class ValidateRegistrationOtpView(generics.GenericAPIView): + """ + Validate the otp sent to user's email. + """ + permission_classes = [] + serializer_class = serializers.ValidateRegistrationOtpSerializer + + @swagger_auto_schema( + responses={200: serializers.UserSerializer} + ) + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + user_serializer = serializers.UserSerializer(user) + return Response( + data=user_serializer.data, + status=status.HTTP_201_CREATED + ) + + +class ValidateForgetPasswordOtpView(generics.GenericAPIView): + """ + Validate the forget password otp sent to user's email. + + Return a token to be used for resetting password. + """ + permission_classes = [] + serializer_class = serializers.ValidateRegistrationOtpSerializer + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + # Create a token for user to reset password + # uidb64 and token are used to identify the user + + return Response( + data=serializer.data, + status=status.HTTP_201_CREATED + ) class TokenVerifyAPIView(APIView): @@ -23,8 +88,6 @@ class TokenVerifyAPIView(APIView): access token is still valid and returns the user info. """ - permission_classes = (PermA,) - @swagger_auto_schema( request_body=serializers.JWTTokenValidateSerializer, responses={200: serializers.UserSerializer} @@ -45,7 +108,6 @@ def post(self, request, format=None): class TokenRefreshAPIView(APIView): - permission_classes = (PermA,) serializer_class = TokenRefreshSerializer @swagger_auto_schema( @@ -63,221 +125,87 @@ def post(self, request, *args, **kwargs): return Response(serializer.validated_data, status=status.HTTP_200_OK) -class LoginAPIView(APIView): - permission_classes = (PermA,) +class LoginAPIView(generics.GenericAPIView): serializer_class = serializers.LoginSerializer + permission_classes = [] @swagger_auto_schema( - request_body=serializers.LoginSerializer, responses={ 200: serializers.LoginResponseSerializer200, - 431: serializers.LoginResponseSerializer431 } ) def post(self, request): serializer = self.serializer_class(data=request.data) - if serializer.is_valid(): - email = serializer.validated_data.get('email') - - user = User.objects.get(email=email) - - if user.is_active: - if user.verified_email: - # Get the user details with the user serializer - s2 = serializers.UserSerializer(user) - - user_details = s2.data - response_data = { - 'tokens': get_tokens_for_user(user), - 'user': user_details - } - # return Response(data=response_data) - return Response(data=response_data) - else: - # Get email tokens for user - uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) - # Generate token for this user - token = account_confirm_token.make_token(user) - tokens = { - 'uidb64': uidb64, - 'token': token, - } - - # full Response - response_data = { - 'tokens': tokens, - 'fullname': user.profile.get_fullname, - 'email': user.email, - } - - return Response(data=response_data, status='431') - else: - return Response(status='432') - else: - return Response(data=serializer.errors, status='400') - - -class ForgetPasswordView(APIView): - permission_classes = (PermA,) - - @swagger_auto_schema( - responses={ - 200: serializers.LoginResponseSerializer431 - } - ) - def post(self, request, *args, **kwargs): - email = kwargs.get('email') - - try: - user = User.objects.get(email=email) - - # Get email tokens for user - uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) - - # Generate token for this user - token = account_confirm_token.make_token(user) - tokens = { - 'uidb64': uidb64, - 'token': token, - } - - # full Response + serializer.is_valid(raise_exception=True) + email = serializer.validated_data.get('email') + user: User = User.objects.get(email=email) + if user.verified_email: + user_details = serializers.UserSerializer(user).data response_data = { - 'tokens': tokens, - 'fullname': user.profile.get_fullname, - 'email': user.email, - } - - return Response(data=response_data, status='200') - - except User.DoesNotExist: - # User email does not exist - pass - - return Response(status='424') - - -class RegisterAPIView(APIView): - permission_classes = (PermA,) - serializer_class = serializers.RegisterSerializer - - def create(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - if serializer.is_valid(): - user = serializer.save() - - # Get the user details with the user serializer - user_serializer = serializers.UserSerializer(user) - - # Get email tokens for user - uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) - # Generate token for this user - token = account_confirm_token.make_token(user) - tokens = { - 'uidb64': uidb64, - 'token': token - } - user_details = user_serializer.data - - response_data = { - 'tokens': tokens, + 'tokens': get_tokens_for_user(user), 'user': user_details } + return Response(data=response_data) - return Response(data=response_data, status='201') + send_verification_email(user, request) + return Response( + data={ + "message": 'Please verify your email first.' + }, status=status.HTTP_400_BAD_REQUEST + ) - return Response(data=serializer.errors, status='400') - - @swagger_auto_schema( - request_body=serializers.RegisterSerializer, - responses={201: serializers.RegisterResponseSerializer} - ) - def post(self, request, *args, **kwargs): - return self.create(request, *args, **kwargs) +class ChangePasswordView(generics.UpdateAPIView): + serializer_class = serializers.ChangePasswordSerializer + http_method_names = ['post'] -class ProfileAPIView(generics.RetrieveUpdateAPIView): - lookup_field = 'id' - permission_classes = (PermB,) - serializer_class = serializers.ProfileSerializer - http_method_names = ['get', 'patch'] + def post(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) def get_object(self): - return get_object_or_404(Profile, user=self.request.user.id) + return self.request.user def get_queryset(self): - return Profile.objects.all() + return User.objects.filter(active=True) -class ForgetChangePasswordView(generics.UpdateAPIView): - permission_classes = (PermA,) - serializer_class = serializers.ForgetChangePasswordSerializer +class RequestForgetPasswordView(generics.GenericAPIView): + serializer_class = serializers.RequestForgetPasswordSerializer + permission_classes = [] - http_method_names = ['patch'] + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + send_password_reset_email(serializer.user, request) + return Response(data=serializer.data) - def get_object(self): - return self.object + +class ForgetPasswordView(generics.GenericAPIView): + serializer_class = serializers.ForgetPasswordSerializer + permission_classes = [] @swagger_auto_schema( - request_body=serializers.ForgetChangePasswordSerializerSwagger, + responses={ + 200: serializers.LoginResponseSerializer200, + } ) - def patch(self, request, *args, **kwargs): - # Get the uid and token from the data passed - uidb64 = request.data.get('uidb64', '') - token = request.data.get('token', '') - - try: - uidb64 = force_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=uidb64) - except (TypeError, ValueError, OverflowError, User.DoesNotExist): - error = Response(status='427') - return error - - # Validate the token - if user is not None: - if account_confirm_token.check_token(user, token): - self.object = user - return super().patch(request, *args, **kwargs) - - error = Response(status='425') - return error - - def get_queryset(self): - return User.objects.filter(active=True) + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + user: User = serializer.validated_data.get('user') + user_details = serializers.UserSerializer(user).data + response_data = { + 'tokens': get_tokens_for_user(user), + 'user': user_details + } + return Response(data=response_data) -class ChangePasswordView(generics.UpdateAPIView): - permission_classes = (PermB,) - serializer_class = serializers.ChangePasswordSerializer - http_method_names = ['patch'] +class UserRetrieveUpdateAPIView(generics.RetrieveUpdateAPIView): + serializer_class = serializers.UserSerializer def get_object(self): return self.request.user def get_queryset(self): return User.objects.filter(active=True) - - -class UserListView(generics.ListAPIView): - permission_classes = (PermB,) - serializer_class = serializers.UserSerializer - - def get_queryset(self): - return User.objects.all().order_by('email') - - -class UserAPIView(generics.RetrieveUpdateAPIView): - permission_classes = (PermB,) - serializer_class = serializers.UserSerializer - http_method_names = ['get', 'patch'] - - def get_queryset(self): - return User.objects.all() - - def get_object(self): - return self.request.user - - def get(self, request, *args, **kwargs): - # Get the user data - response_data = self.get_serializer_class()(request.user).data - return Response(data=response_data) diff --git a/src/account/forms.py b/src/account/forms.py index 8041ea5..a763118 100644 --- a/src/account/forms.py +++ b/src/account/forms.py @@ -1,71 +1,67 @@ from django import forms from django.contrib.auth import password_validation -from django.contrib.auth.forms import ReadOnlyPasswordHashField - -from .models import User, Profile from utils.base.validators import validate_special_char +from utils.base.constants import User -# User form for admin class UserRegisterForm(forms.ModelForm): - password=forms.CharField(label="Password", - widget=forms.PasswordInput, - min_length=8, - help_text=password_validation.password_validators_help_text_html()) - password2=forms.CharField(label="Confirm password", - widget=forms.PasswordInput, - help_text='Must be similar to first password to pass verification') - - username = forms.CharField(max_length=20, label='Profile Username', help_text='Enter a unique username for this user', required=False) - account_type = forms.ChoiceField(choices=Profile.ACCOUNT_TYPES, required=False) + password = forms.CharField( + label="Password", + widget=forms.PasswordInput, + min_length=8, + help_text=password_validation.password_validators_help_text_html()) + password2 = forms.CharField( + label="Confirm password", + widget=forms.PasswordInput, + help_text='Must be similar to first password to pass verification') + fullname = forms.CharField( + label="Fullname", + help_text="Enter your fullname", + validators=[validate_special_char], + ) - class Meta: - model=User - fields=("email","username","password","password2",) - - # Validate the username if unique - def clean_username(self): - username = self.cleaned_data.get("username") + class Meta: + model = User + fields = ( + "email", "username", + "password", "password2", + "fullname" + ) - # Validate the username has only valid chars - validate_special_char(username) - - # Does username already exist - if Profile.objects.filter(username__exact=username).exists(): - raise forms.ValidationError('Username name is not available') + def clean_password(self): + """Cleaning password one to check if + all validations are met + """ - return username + ps1 = self.cleaned_data.get("password") + password_validation.validate_password(ps1, None) + return ps1 - # Cleaning password one to check if all validations are met - def clean_password(self): - ps1=self.cleaned_data.get("password") - password_validation.validate_password(ps1,None) - return ps1 + def clean_password2(self): + """Override clean on password2 level to + compare similarities of password + """ - """Override clean on password2 level to compare similarities of password""" - def clean_password2(self): - ps1=self.cleaned_data.get("password") - ps2=self.cleaned_data.get("password2") - if (ps1 and ps2) and (ps1 != ps2): - raise forms.ValidationError("The passwords does not match") - return ps2 - - """ Override the default save method to use set_password method to convert text to hashed """ - def save(self, commit=True): - user=super(UserRegisterForm, self).save(commit=False) - user.set_password(self.cleaned_data.get("password")) + ps1 = self.cleaned_data.get("password") + ps2 = self.cleaned_data.get("password2") + if (ps1 and ps2) and (ps1 != ps2): + raise forms.ValidationError("The passwords does not match") + return ps2 - if commit: - user.save() + def save(self, commit=True): + """ + Override the default save method to use set_password + method to convert text to hashed + """ + user: User = super(UserRegisterForm, self).save(commit=False) + user.set_password(self.cleaned_data.get("password")) - # Profile is already created, update values with data in form - profile = user.profile - username = self.cleaned_data.get('username') - account_type = self.cleaned_data.get('account_type') + if commit: + user.save() - # Add data - profile.username = username if username else '' - profile.account_type = account_type if account_type else '' - profile.save() + # Profile is already created, update values with data in form + profile = user.profile + profile.fullname = self.cleaned_data.get('fullname') + profile.save() - return user \ No newline at end of file + return user diff --git a/src/account/migrations/0001_initial.py b/src/account/migrations/0001_initial.py new file mode 100644 index 0000000..8266cc1 --- /dev/null +++ b/src/account/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 4.0 on 2023-04-13 14:29 + +from django.db import migrations, models +import django.db.models.deletion +import utils.base.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + 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')), + ('email', models.EmailField(max_length=254, unique=True)), + ('username', models.CharField(help_text='Username must be unique and must not contain special characters', max_length=60, unique=True, validators=[utils.base.validators.validate_special_char])), + ('active', models.BooleanField(default=True)), + ('staff', models.BooleanField(default=False)), + ('admin', models.BooleanField(default=False)), + ('verified_email', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Interest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True)), + ], + ), + migrations.CreateModel( + name='Personality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True)), + ], + ), + migrations.CreateModel( + name='Skill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True)), + ], + ), + migrations.CreateModel( + name='UsedResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=100)), + ('created', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'verbose_name_plural': 'Used Reset Tokens', + }, + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fullname', models.CharField(blank=True, max_length=30, validators=[utils.base.validators.validate_special_char])), + ('bio', models.TextField(blank=True, max_length=1000)), + ('github', models.URLField(blank=True)), + ('twitter', models.URLField(blank=True)), + ('interest', models.ManyToManyField(blank=True, to='account.Interest')), + ('personality', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='account.personality')), + ('skills', models.ManyToManyField(blank=True, to='account.Skill')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + ), + ] diff --git a/src/account/models.py b/src/account/models.py index 3788cef..08669cd 100644 --- a/src/account/models.py +++ b/src/account/models.py @@ -1,25 +1,26 @@ -from typing import TypeVar + from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.db import models -from utils.base.general import send_email -from utils.base.validators import validate_special_char, validate_phone -from django.dispatch import receiver from django.db.models.signals import post_save +from django.dispatch import receiver - -T = TypeVar('T', bound=AbstractBaseUser) +from utils.base.general import random_otp, send_email +from utils.base.validators import validate_special_char +from django.core.cache import cache +from django.conf import settings class UserManager(BaseUserManager): def create_base_user( - self, email, is_active=True, - is_staff=False, is_admin=False - ) -> T: + self, email, username, is_active=True, + is_staff=False, is_admin=False + ) -> AbstractBaseUser: if not email: raise ValueError("User must provide an email") user: User = self.model( - email=self.normalize_email(email) + email=self.normalize_email(email), + username=username, ) user.active = is_active user.admin = is_admin @@ -29,77 +30,74 @@ def create_base_user( return user def create_user( - self, email, password=None, is_active=True, - is_staff=False, is_admin=False - ) -> T: - user = self.create_base_user(email, is_active, is_staff, is_admin) + self, email, username, password=None, is_active=True, + is_staff=False, is_admin=False + ): if not password: raise ValueError("User must provide a password") + user = self.create_base_user( + email, username, is_active, is_staff, is_admin) user.set_password(password) user.save() return user - def create_staff(self, email, password=None) -> T: - user = self.create_user(email=email, password=password, is_staff=True) + def create_staff(self, email, username, password=None): + user = self.create_user( + email, username, password, is_staff=True) return user - def create_superuser(self, email, password=None) -> T: + def create_superuser(self, email, username, password=None): user = self.create_user( - email=email, password=password, is_staff=True, is_admin=True) + email, username, password, is_staff=True, is_admin=True) return user def get_staffs(self): return self.filter(staff=True) - def get_admins(self): - return self.filter(admin=True) - class User(AbstractBaseUser): email = models.EmailField(unique=True) - - # Admin fields + username = models.CharField( + max_length=60, unique=True, + validators=[validate_special_char], + help_text="Username must be unique and \ +must not contain special characters" + ) active = models.BooleanField(default=True) staff = models.BooleanField(default=False) admin = models.BooleanField(default=False) - created = models.DateTimeField(auto_now=True) verified_email = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) - REQUIRED_FIELDS = [] + REQUIRED_FIELDS = ["username"] USERNAME_FIELD = "email" objects = UserManager() - def has_perm(self, perm, obj=None): + def has_perm(self, perm, obj=None): # pragma: no cover return True - def has_module_perms(self, app_label): + def has_module_perms(self, app_label): # pragma: no cover return True - @property - def username(self) -> str: - return self.profile.username + def __str__(self) -> str: + return self.username - @property - def get_emailname(self) -> str: - """Return the x part of an email e.g [x]@gmail.com""" - return self.email.split('@')[0] + def set_otp(self): + otp = random_otp() + cache.set(f"otp_{self.pk}", otp, timeout=settings.OTP_CACHE_TIMEOUT) + return otp - def __str__(self) -> str: - return self.email + def validate_otp(self, otp): + valid = cache.get(f"otp_{self.pk}") == otp + if valid: + cache.delete(f"otp_{self.pk}") + return valid def email_user(self, subject, message): val = send_email(subject=subject, message=message, email=self.email) return True if val else False - @property - def first_name(self) -> str: - return self.profile.first_name - - @property - def last_name(self) -> str: - return self.profile.last_name - @property def is_active(self) -> bool: return self.active @@ -112,32 +110,45 @@ def is_staff(self) -> bool: def is_admin(self) -> bool: return self.admin + @property + def is_personnel(self) -> bool: + return self.staff or self.admin -class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) - username = models.CharField( - max_length=60, unique=True, validators=[validate_special_char]) - created = models.DateTimeField(auto_now=True) - first_name = models.CharField( - max_length=30, validators=[validate_special_char]) - last_name = models.CharField( - max_length=30, validators=[validate_special_char]) +class Skill(models.Model): + name = models.CharField(max_length=30, unique=True) - phone = models.CharField(max_length=20, validators=[validate_phone]) - image = models.ImageField( - upload_to='accounts/profiles', null=True, blank=True) + def __str__(self) -> str: + return self.name.title() - address = models.CharField(max_length=200, blank=True) - city = models.CharField(max_length=60, blank=True) - state = models.CharField(max_length=60, blank=True) - zip = models.CharField(max_length=6, blank=True) - about = models.TextField(max_length=2500, blank=True) +class Interest(models.Model): + name = models.CharField(max_length=30, unique=True) + + def __str__(self) -> str: + return self.name.title() + + +class Personality(models.Model): + name = models.CharField(max_length=30, unique=True) + + def __str__(self) -> str: + return self.name.title() - @property - def fullname(self): - return f"{self.first_name} {self.last_name}" + +class Profile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + fullname = models.CharField( + max_length=30, + validators=[validate_special_char], + blank=True) + bio = models.TextField(max_length=1000, blank=True) + github = models.URLField(blank=True) + twitter = models.URLField(blank=True) + skills = models.ManyToManyField(Skill, blank=True) + interest = models.ManyToManyField(Interest, blank=True) + personality = models.ForeignKey( + Personality, on_delete=models.SET_NULL, null=True, blank=True) def __str__(self) -> str: return self.get_fullname @@ -146,12 +157,20 @@ def __str__(self) -> str: def get_fullname(self) -> str: return self.fullname.title() - def get_user_name_with_id(self) -> str: - user_name = self.first_name + self.last_name - return f"{user_name}-{self.user.id}" - @receiver(post_save, sender=User) def create_profile(sender, instance, created, **kwargs): if created: Profile.objects.create(user=instance) + + +class UsedResetToken(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + token = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.user.email + + class Meta: + verbose_name_plural = "Used Reset Tokens" diff --git a/src/account/templates/account/email/base.html b/src/account/templates/account/email/base.html new file mode 100644 index 0000000..72dae17 --- /dev/null +++ b/src/account/templates/account/email/base.html @@ -0,0 +1,50 @@ +{% load static %} + + + + + + {{ APP_NAME }} + + + + + + + + + + + + + + + + + {% block content %} + {% endblock content %} + + + + + + + \ No newline at end of file diff --git a/src/account/templates/account/email/email.css b/src/account/templates/account/email/email.css new file mode 100644 index 0000000..216ac05 --- /dev/null +++ b/src/account/templates/account/email/email.css @@ -0,0 +1,579 @@ +/* Spacing css */ + +.m-0 { +margin: 0 !important; +} + +.m-1 { +margin: 0.25rem !important; +} + +.m-2 { +margin: 0.5rem !important; +} + +.m-3 { +margin: 1rem !important; +} + +.m-4 { +margin: 1.5rem !important; +} + +.m-5 { +margin: 3rem !important; +} + +.m-auto { +margin: auto !important; +} + +.mx-0 { +margin-right: 0 !important; +margin-left: 0 !important; +} + +.mx-1 { +margin-right: 0.25rem !important; +margin-left: 0.25rem !important; +} + +.mx-2 { +margin-right: 0.5rem !important; +margin-left: 0.5rem !important; +} + +.mx-3 { +margin-right: 1rem !important; +margin-left: 1rem !important; +} + +.mx-4 { +margin-right: 1.5rem !important; +margin-left: 1.5rem !important; +} + +.mx-5 { +margin-right: 3rem !important; +margin-left: 3rem !important; +} + +.mx-auto { +margin-right: auto !important; +margin-left: auto !important; +} + +.my-0 { +margin-top: 0 !important; +margin-bottom: 0 !important; +} + +.my-1 { +margin-top: 0.25rem !important; +margin-bottom: 0.25rem !important; +} + +.my-2 { +margin-top: 0.5rem !important; +margin-bottom: 0.5rem !important; +} + +.my-3 { +margin-top: 1rem !important; +margin-bottom: 1rem !important; +} + +.my-4 { +margin-top: 1.5rem !important; +margin-bottom: 1.5rem !important; +} + +.my-5 { +margin-top: 3rem !important; +margin-bottom: 3rem !important; +} + +.my-auto { +margin-top: auto !important; +margin-bottom: auto !important; +} + +.mt-0 { +margin-top: 0 !important; +} + +.mt-1 { +margin-top: 0.25rem !important; +} + +.mt-2 { +margin-top: 0.5rem !important; +} + +.mt-3 { +margin-top: 1rem !important; +} + +.mt-4 { +margin-top: 1.5rem !important; +} + +.mt-5 { +margin-top: 3rem !important; +} + +.mt-auto { +margin-top: auto !important; +} + +.me-0 { +margin-right: 0 !important; +} + +.me-1 { +margin-right: 0.25rem !important; +} + +.me-2 { +margin-right: 0.5rem !important; +} + +.me-3 { +margin-right: 1rem !important; +} + +.me-4 { +margin-right: 1.5rem !important; +} + +.me-5 { +margin-right: 3rem !important; +} + +.me-auto { +margin-right: auto !important; +} + +.mb-0 { +margin-bottom: 0 !important; +} + +.mb-1 { +margin-bottom: 0.25rem !important; +} + +.mb-2 { +margin-bottom: 0.5rem !important; +} + +.mb-3 { +margin-bottom: 1rem !important; +} + +.mb-4 { +margin-bottom: 1.5rem !important; +} + +.mb-5 { +margin-bottom: 3rem !important; +} + +.mb-auto { +margin-bottom: auto !important; +} + +.ms-0 { +margin-left: 0 !important; +} + +.ms-1 { +margin-left: 0.25rem !important; +} + +.ms-2 { +margin-left: 0.5rem !important; +} + +.ms-3 { +margin-left: 1rem !important; +} + +.ms-4 { +margin-left: 1.5rem !important; +} + +.ms-5 { +margin-left: 3rem !important; +} + +.ms-auto { +margin-left: auto !important; +} + +.p-0 { +padding: 0 !important; +} + +.p-1 { +padding: 0.25rem !important; +} + +.p-2 { +padding: 0.5rem !important; +} + +.p-3 { +padding: 1rem !important; +} + +.p-4 { +padding: 1.5rem !important; +} + +.p-5 { +padding: 3rem !important; +} + +.px-0 { +padding-right: 0 !important; +padding-left: 0 !important; +} + +.px-1 { +padding-right: 0.25rem !important; +padding-left: 0.25rem !important; +} + +.px-2 { +padding-right: 0.5rem !important; +padding-left: 0.5rem !important; +} + +.px-3 { +padding-right: 1rem !important; +padding-left: 1rem !important; +} + +.px-4 { +padding-right: 1.5rem !important; +padding-left: 1.5rem !important; +} + +.px-5 { +padding-right: 3rem !important; +padding-left: 3rem !important; +} + +.py-0 { +padding-top: 0 !important; +padding-bottom: 0 !important; +} + +.py-1 { +padding-top: 0.25rem !important; +padding-bottom: 0.25rem !important; +} + +.py-2 { +padding-top: 0.5rem !important; +padding-bottom: 0.5rem !important; +} + +.py-3 { +padding-top: 1rem !important; +padding-bottom: 1rem !important; +} + +.py-4 { +padding-top: 1.5rem !important; +padding-bottom: 1.5rem !important; +} + +.py-5 { +padding-top: 3rem !important; +padding-bottom: 3rem !important; +} + +.pt-0 { +padding-top: 0 !important; +} + +.pt-1 { +padding-top: 0.25rem !important; +} + +.pt-2 { +padding-top: 0.5rem !important; +} + +.pt-3 { +padding-top: 1rem !important; +} + +.pt-4 { +padding-top: 1.5rem !important; +} + +.pt-5 { +padding-top: 3rem !important; +} + +.pe-0 { +padding-right: 0 !important; +} + +.pe-1 { +padding-right: 0.25rem !important; +} + +.pe-2 { +padding-right: 0.5rem !important; +} + +.pe-3 { +padding-right: 1rem !important; +} + +.pe-4 { +padding-right: 1.5rem !important; +} + +.pe-5 { +padding-right: 3rem !important; +} + +.pb-0 { +padding-bottom: 0 !important; +} + +.pb-1 { +padding-bottom: 0.25rem !important; +} + +.pb-2 { +padding-bottom: 0.5rem !important; +} + +.pb-3 { +padding-bottom: 1rem !important; +} + +.pb-4 { +padding-bottom: 1.5rem !important; +} + +.pb-5 { +padding-bottom: 3rem !important; +} + +.ps-0 { +padding-left: 0 !important; +} + +.ps-1 { +padding-left: 0.25rem !important; +} + +.ps-2 { +padding-left: 0.5rem !important; +} + +.ps-3 { +padding-left: 1rem !important; +} + +.ps-4 { +padding-left: 1.5rem !important; +} + +.ps-5 { +padding-left: 3rem !important; +} + + + + + + +/* Base css */ +*{ + padding: 0; + margin: 0; + font-family: 'Montserrat', sans-serif; +} +:root{ + --pitch-dark: #000000; + --bs-warning: #F79226; + --bs-warning-deep: #db7f1c; + --grey-bg: #E5E5E5; + --grey-bg-2: #F2F1F0; +} +.fw-500{ + font-weight: 500; +} +p, li{ + font-size: .9rem; + line-height: 1.8; + margin: 1rem 0; +} +li{ + margin: 0; +} + +a{ + text-decoration: none; + font-weight: bold; +} + +ul{ + list-style: none; +} + +.message-to-reply li{ + display: flex; + align-items: center; + margin-bottom: .4rem; +} +.message-to-reply li span:first-child{ + width: 100%; + max-width: 200px; +} +.message-to-reply li span:last-child{ + font-weight: 500; +} + +.navbar{ + display: flex; + align-items: center; + justify-content: space-between; + padding: 4rem 0 2.5rem 0; +} +.navbar .date{ + font-size: .9rem; + font-weight: 500; +} +.container-fluid{ + max-width: 1000px; + padding: 0 .8rem; + margin: 0 auto; +} +.subject{ + font-size: 1.3rem; +} +.subject-container{ + text-align: center; + margin-bottom: 2.5rem; +} +.subject-container p{ + margin: 0; + margin-top: .5rem; +} +.banner img{ + width: 100%; + max-width: 800px; + display: block; + margin: 0 auto; +} + +.content{ + margin: 2rem 0 3rem 0; +} + +footer{ + text-align: center; +} +footer p{ + font-size: .8rem; + font-weight: 500; +} + + +.reply{ + font-size: 1rem; + /* font-weight: 500; */ + margin-top: 0; +} +.reply-head{ + margin-bottom: 0; + margin-top: 2.5rem; + font-weight: bold; +} + +.text-center { + text-align: center !important; +} + +.btn{ + padding: .7rem 2rem; + border-radius: .3rem; + background: #155aaa; + color: #fff !important; + font-weight: bold; + display: block; + width: fit-content; + margin: 1rem auto; +} + +.link-copy{ + padding: .5rem; + background: #f1f1f1; +} + + + +/* tables */ +.table-container{ + max-width: 800px; + margin: 0 auto; +} +.normal-table{ + text-align: left; + width: 100%; + margin-bottom: 2rem; +} +.normal-table td{ + font-size: .9rem; + white-space: nowrap; +} +.normal-table th, .normal-table td{ + padding: .3rem 1rem; +} + +.custom-table{ + border-collapse: collapse; + width: 100%; + text-align: left; +} +.custom-table th, .custom-table td{ + padding: .5rem 1rem; +} +.custom-table tbody tr{ + background-color: #f6f3f3; + font-size: .9rem; +} +.custom-table tbody tr:nth-child(2){ + background-color: rgba(247, 146, 38, 0.116); +} +.custom-table thead, .custom-table tbody tr.total { + background-color: rgba(247, 146, 38, 0.383) !important; + font-size: 1rem; + font-weight: 500; +} + +.download{ + color: var(--bs-warning); + font-size: .8rem; + display: flex; + align-items: center; + width: fit-content; +} +.download span{ + transform: scale(.7); + margin-right: 0.2rem; +} + +@media(max-width: 769px){ + .table-container{ + overflow-x: scroll; + } +} \ No newline at end of file diff --git a/src/account/templates/account/email/password_reset.html b/src/account/templates/account/email/password_reset.html new file mode 100644 index 0000000..9a7ca40 --- /dev/null +++ b/src/account/templates/account/email/password_reset.html @@ -0,0 +1,23 @@ +{% extends 'account/email/base.html' %} + +{% load static %} + +{% block content %} + +
+
+

Reset Password OTP

+ +

Please reset your account password using the OTP (One Time Passsword) provided below

+ +

{{otp}}

+ +

+ If you did not request a password reset, please ignore this email. +

+ +

Have a question? Send us a messge or reply to this mail.

+
+
+ +{% endblock content %} diff --git a/src/account/templates/account/email/test.html b/src/account/templates/account/email/test.html new file mode 100644 index 0000000..03c9d1a --- /dev/null +++ b/src/account/templates/account/email/test.html @@ -0,0 +1,672 @@ + + + + + + + RoadFlow + + + + + + + + + + + + + + + + + + +
+
+

Thanks for registering.

+

Enjoy our AI roadmap recommendation system.

+ +

Please verify your account using the OTP (One Time Passsword) provided below

+ +

404665

+ +

+ If you did not register, please ignore this email. +

+ +

Have a question? Send us a messge or reply to this mail.

+
+
+ + + + + + + + + \ No newline at end of file diff --git a/src/account/templates/account/email/verify_email.html b/src/account/templates/account/email/verify_email.html new file mode 100644 index 0000000..0c46704 --- /dev/null +++ b/src/account/templates/account/email/verify_email.html @@ -0,0 +1,24 @@ +{% extends 'account/email/base.html' %} + +{% load static %} + +{% block content %} + +
+
+

Thanks for registering.

+

Enjoy our AI roadmap recommendation system.

+ +

Please verify your account using the OTP (One Time Passsword) provided below

+ +

{{otp}}

+ +

+ If you did not register, please ignore this email. +

+ +

Have a question? Send us a messge or reply to this mail.

+
+
+ +{% endblock content %} \ No newline at end of file diff --git a/src/config/settings/base.py b/src/config/settings/base.py index 3aaec57..f424a98 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -1,7 +1,6 @@ from datetime import timedelta from pathlib import Path -from corsheaders.defaults import default_headers from decouple import config BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -19,7 +18,6 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - "corsheaders", 'drf_yasg', # Needed to be added to have access to @@ -27,12 +25,11 @@ # 'django.contrib.postgres', 'account', - 'project_api_key', ] REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', + 'utils.base.permissions.IsAuthenticated', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( @@ -48,11 +45,23 @@ ), } -API_KEY_HEADER = "HTTP_BEARER_API_KEY" -API_SEC_KEY_HEADER = "HTTP_BEARER_SEC_API_KEY" +SWAGGER_SETTINGS = { + 'DEFAULT_AUTO_SCHEMA_CLASS': 'utils.base.schema.BaseSchema', + "SECURITY_DEFINITIONS": { + "JWT [Bearer {TOKEN}]": { + "name": "Authorization", + "type": "apiKey", + "in": "header", + }, + "Basic": { + "type": "basic", + "name": "Authorization", + } + }, + "USE_SESSION_AUTH": False, +} MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -63,13 +72,6 @@ ] -CORS_ALLOWED_ORIGINS = [] - -CORS_ALLOW_HEADERS = list(default_headers) + [ - "Bearer-Api-Key", - "Bearer-Sec-Api-Key", -] - ROOT_URLCONF = 'config.urls' AUTH_USER_MODEL = 'account.User' WSGI_APPLICATION = 'config.wsgi.application' @@ -106,10 +108,9 @@ ] -USE_CACHE = config("USE_CACHE", default=False, cast=bool) -REDIS_LOCATION = config("REDIS_LOCATION", default='redis://127.0.0.1:6379') +REDIS_LOCATION = config("REDIS_LOCATION", default="") -if USE_CACHE: +if REDIS_LOCATION: CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', @@ -131,15 +132,24 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': config("DB_NAME", default=''), - 'USER': config("DB_USER", default=''), - 'PASSWORD': config("DB_PASSWORD", default=''), - 'HOST': 'localhost', - 'PORT': '', + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', } } +DB_NAME = config("DB_NAME", default='') + +if DB_NAME: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': DB_NAME, + 'USER': config("DB_USER", default=''), + 'PASSWORD': config("DB_PASSWORD", default=''), + 'HOST': 'localhost', + } + } + STATIC_URL = '/static/' @@ -212,3 +222,12 @@ OFF_EMAIL = True MAX_IMAGE_UPLOAD_SIZE = 2621440 # 2.5MB + +APP_NAME = 'RoadFlow' + +USERNAME_PREFIX = 'roadflow' + +SECRET_LENGTH_START = 32 +SECRET_LENGTH_STOP = 64 + +OTP_CACHE_TIMEOUT = 30 * 60 # 30 minutes diff --git a/src/config/settings/test.py b/src/config/settings/test.py index 603af9f..fc5ff5e 100644 --- a/src/config/settings/test.py +++ b/src/config/settings/test.py @@ -8,26 +8,27 @@ "tests" ] -DB_DEFAULT = config("DB_DEFAULT", default="sqlite") +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'test.db.sqlite3', + }, +} -if DB_DEFAULT == "postgres": +TEST_DB_USER = config("TEST_DB_USER", default="") + +if TEST_DB_USER: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': "trip_testing_fake_db", - 'USER': config("TEST_DB_USER"), + 'USER': TEST_DB_USER, 'PASSWORD': config("TEST_DB_PASSWORD"), 'HOST': 'localhost', 'PORT': '', } } -else: - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'test.db.sqlite3', - }, - } + # use default loc mem cache for tests CACHES['default']["BACKEND"] = 'django.core.cache.backends.locmem.LocMemCache' diff --git a/src/project_api_key/admin.py b/src/project_api_key/admin.py deleted file mode 100644 index 241f60e..0000000 --- a/src/project_api_key/admin.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.contrib import admin, messages - -from .models import ProjectApiKey - - -class ProjectApiKeyAdmin(admin.ModelAdmin): - list_display = ( - "user", - "pub_key", - ) - search_fields = ("user", "pub_key") - - def save_model(self, request, obj: ProjectApiKey, *args, **kwargs): - created = not obj.pk - - if created: - obj.save() - - # Get the cached pass_key - key = obj.get_cached_pass_key() - - message = ( - "The API Secret key for {} is: {} ".format(obj.user, key) + - "Please store it somewhere safe: " + - "you will not be able to see it again." - ) - messages.add_message(request, messages.WARNING, message) - - -admin.site.register(ProjectApiKey, ProjectApiKeyAdmin) diff --git a/src/project_api_key/apps.py b/src/project_api_key/apps.py deleted file mode 100644 index c663c19..0000000 --- a/src/project_api_key/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ProjectApiKeyConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'project_api_key' diff --git a/src/project_api_key/migrations/__init__.py b/src/project_api_key/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/project_api_key/models.py b/src/project_api_key/models.py deleted file mode 100644 index 37bfebc..0000000 --- a/src/project_api_key/models.py +++ /dev/null @@ -1,73 +0,0 @@ -from django.contrib.auth.hashers import check_password, make_password -from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils.crypto import get_random_string -from django.core.cache import cache - - -class ProjectApiKey(models.Model): - """ - This is a model to store api keys for projects - """ - - cache_timeout = 30 - user = models.ForeignKey('authentication.User', on_delete=models.CASCADE) - pub_key = models.CharField(max_length=64, editable=False) - sec_key = models.CharField(max_length=255, editable=False) - - def __str__(self): - return self.pub_key or "Not created" - - def cache_pass_key(self, key: str): - """ - This method is to cache the unhashed pass key - """ - cache.set(self.pub_key, key, timeout=self.cache_timeout) - - def get_cached_pass_key(self) -> str: - """ - This method is to get the unhashed sec_key - """ - return cache.get(self.pub_key) - - def set_sec_key(self, key: str): - """ - This method is to set the hashed sec_key - """ - self.sec_key = make_password(key) - - def check_password(self, sec_key): - return check_password(sec_key, self.sec_key) - - def is_active(self): - """ - This method is to check if the user is active - """ - return self.user.is_active - - def is_staff(self): - """ - This method is to check if the user is staff - """ - return (self.user.staff or self.user.admin) and self.is_active() - - class Meta: - verbose_name = "Api key" - verbose_name_plural = "Api keys" - - -@receiver(post_save, sender=ProjectApiKey) -def create_project_api(sender, instance, created, **kwargs): - if created: - # Generate random pub_key and pass - pub_key = get_random_string(16) - pass_key = f"{get_random_string(6)}.{get_random_string(32)}.{get_random_string(16)}" # noqa - - instance.pub_key = pub_key - instance.set_sec_key(pass_key) - - # Cache the pass_key - instance.cache_pass_key(pass_key) - - instance.save() diff --git a/src/project_api_key/permissions.py b/src/project_api_key/permissions.py deleted file mode 100644 index 0737152..0000000 --- a/src/project_api_key/permissions.py +++ /dev/null @@ -1,77 +0,0 @@ -from account.models import User -from django.conf import settings -from rest_framework import permissions -from rest_framework_simplejwt.models import TokenUser -from utils.base.logger import err_logger, logger # noqa - -from .models import ProjectApiKey - - -def check_user_set(request) -> bool: - """ - Check if the user is a Token user - and set user to request - """ - if isinstance(request.user, TokenUser): - try: - # Get the real user object - user = User.objects.get(id=request.user.id) - request.user = user - except User.DoesNotExist: - return False - return True - - -class HasStaffProjectAPIKey(permissions.BasePermission): - """ - This is a permission class to validate api keys - belongs to staffs and admins only - """ - - def has_permission(self, request, view): - valid, api_obj = self.validate_apikey(request) - - if valid: - if not check_user_set(request): - return False - return api_obj.is_staff() - - def validate_apikey(self, request): - custom_header = settings.API_KEY_HEADER - custom_sec_header = settings.API_SEC_KEY_HEADER - - pub_key = self.get_from_header(request, custom_header) - sec_key = self.get_from_header(request, custom_sec_header) - - try: - api_obj = ProjectApiKey.objects.select_related( - 'user').get(pub_key=pub_key) - except ProjectApiKey.DoesNotExist: - return False, None - - return api_obj.check_password(sec_key), api_obj - - def get_from_header(self, request, name): - """ - Get the api key from the request header - """ - return request.META.get(name) - - -class HasProjectAPIKey(HasStaffProjectAPIKey): - """ - This is a permission class to validate api keys is valid - """ - - def has_permission(self, request, view): - key, api_obj = self.validate_apikey(request) - - if key: - if not check_user_set(request): - return False - return api_obj.is_active() - - -# Create instance to use on auth permissions and others -has_staff_key = HasStaffProjectAPIKey() -has_project_key = HasProjectAPIKey() diff --git a/src/project_api_key/__init__.py b/src/tests/account/__init__.py similarity index 100% rename from src/project_api_key/__init__.py rename to src/tests/account/__init__.py diff --git a/src/tests/account/test_models.py b/src/tests/account/test_models.py new file mode 100644 index 0000000..d63676e --- /dev/null +++ b/src/tests/account/test_models.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.mark.django_db +class TestUserModel: + def test_email_user(self, user): + assert user.email_user('subject', 'message') + + def test_is_active(self, user): + user.active = True + assert user.is_active + + def test_is_staff(self, user): + user.staff = True + assert user.is_staff + + def test_is_admin(self, user): + user.admin = True + assert user.is_admin + + def test_str(self, user): + assert str(user) == user.username diff --git a/src/tests/conftest.py b/src/tests/conftest.py index ca1790d..50dbb9a 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,21 +1,16 @@ -import datetime +from io import BytesIO from typing import Callable, Dict, TypeVar from unittest import TestCase -from io import BytesIO -from PIL import Image -from django.core.files.base import File import pytest from django.contrib.admin import AdminSite +from django.core.files.base import File from model_bakery import baker +from PIL import Image from rest_framework.test import APIClient from utils.base.constants import User from utils.base.general import get_tokens_for_user -from business.models import Business -from project_api_key.models import ProjectApiKey - - U = TypeVar('U', bound=TestCase) tcase = TestCase() @@ -74,7 +69,6 @@ def inactive_user(): def user_with_no_profile(): return baker.make( User, active=True, - first_name='John', last_name='Doe' ) @@ -82,8 +76,6 @@ def user_with_no_profile(): def user(): user = baker.make( User, active=True, - first_name='John', last_name='Doe', - phone='+2348123456789', ) user.set_password('test1234') user.save() @@ -105,24 +97,6 @@ def admin(): ) -@pytest.fixture -def default_currency(settings): - return baker.make( - 'exchange.Currency', - symbol=settings.DEFAULT_CURRENCY - ) - - -@pytest.fixture -def business(user): - return Business.objects.create( - name='Test Business', - user=user, - business_type='starter', - dob=datetime.date(2000, 1, 1), - ) - - @pytest.fixture def message_storage(): return DummyStorage() @@ -146,7 +120,7 @@ def test_case(): @pytest.fixture -def keyless_base_client(): +def base_client(): def inner(method: str = "post"): client = API_CLIENT_METHODS[method] @@ -165,53 +139,9 @@ def child(url: str, data: dict = None, headers: dict = None): return inner -@pytest.fixture -def keyless_post(keyless_base_client): - return keyless_base_client() - - -@pytest.fixture -def keyless_get(keyless_base_client): - return keyless_base_client('get') - - -@pytest.fixture -def keyless_delete(keyless_base_client): - return keyless_base_client('delete') - - -@pytest.fixture -def keyless_patch(keyless_base_client): - return keyless_base_client('patch') - - -@pytest.fixture -def keyless_put(keyless_base_client): - return keyless_base_client('put') - - -@pytest.fixture -def base_client(admin_api_key_headers, keyless_base_client): - - def inner(method=None): - - def child(url: str, data: dict = None, headers: dict = None): - - if headers is None: - headers = {} - - headers.update(admin_api_key_headers) - - return keyless_base_client(method)(url, data, headers) - - return child - - return inner - - @pytest.fixture def post(base_client): - return base_client("post") + return base_client() @pytest.fixture @@ -276,27 +206,6 @@ def logged_patch(logged_client): return logged_client('patch') -def _get_api_key_headers(user, settings): - project_api_key = ProjectApiKey.objects.create(user=user) - - sec_key = project_api_key.get_cached_pass_key() - pub_key = project_api_key.pub_key - return { - settings.API_KEY_HEADER: pub_key, - settings.API_SEC_KEY_HEADER: sec_key, - } - - -@pytest.fixture -def admin_api_key_headers(admin, settings): - return _get_api_key_headers(admin, settings) - - -@pytest.fixture -def basic_api_key_headers(user, settings): - return _get_api_key_headers(user, settings) - - @pytest.fixture def dummy_request(): class Request: diff --git a/src/tests/project_api_key/__init__.py b/src/tests/project_api_key/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tests/project_api_key/test_admin.py b/src/tests/project_api_key/test_admin.py deleted file mode 100644 index 6be75d6..0000000 --- a/src/tests/project_api_key/test_admin.py +++ /dev/null @@ -1,34 +0,0 @@ -from project_api_key.admin import ProjectApiKeyAdmin -from project_api_key.models import ProjectApiKey -import pytest - - -@pytest.fixture -def project_api_key_admin(admin_site): - return ProjectApiKeyAdmin(ProjectApiKey, admin_site) - - -@pytest.fixture -def api_obj(user): - return ProjectApiKey(user=user) - - -@pytest.mark.django_db -def test_save_model( - request_storage, project_api_key_admin, - api_obj: ProjectApiKey -): - - request, storage = request_storage - project_api_key_admin.save_model(request, api_obj) - - key = api_obj.get_cached_pass_key() - assert key is not None - - message = ( - "The API Secret key for {} is: {} ".format(api_obj.user, key) + - "Please store it somewhere safe: " + - "you will not be able to see it again." - ) - - assert message in storage.store diff --git a/src/tests/project_api_key/test_model.py b/src/tests/project_api_key/test_model.py deleted file mode 100644 index 0f74716..0000000 --- a/src/tests/project_api_key/test_model.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest - -from project_api_key.models import ProjectApiKey -from django.contrib.auth.hashers import check_password -from django.core.cache import cache - - -@pytest.fixture -def api_key(user): - return ProjectApiKey.objects.create(user=user) - - -@pytest.mark.django_db -class TestProjectApiKey: - def test_str_saved(self, api_key): - assert str(api_key) == api_key.pub_key - - def test_str_not_saved(self, user): - api_key = ProjectApiKey(user=user) - assert str(api_key) == "Not created" - - def test_create_project_api(self, api_key): - assert api_key is not None - assert api_key.pub_key is not None - assert api_key.sec_key is not None - assert api_key.get_cached_pass_key() is not None - - def test_cache_pass_key(self, api_key): - api_key.cache_pass_key("test") - assert cache.get(api_key.pub_key) == "test" - - def test_get_cached_pass_key(self, api_key): - cache.set(api_key.pub_key, "test", timeout=api_key.cache_timeout) - assert api_key.get_cached_pass_key() == "test" - - def test_set_sec_key(self, api_key): - api_key.set_sec_key("test") - assert check_password("test", api_key.sec_key) - assert check_password("test1", api_key.sec_key) is False - - def test_check_password(self, api_key): - api_key.set_sec_key("test") - assert api_key.check_password("test") - assert api_key.check_password("test1") is False - - def test_is_active(self, api_key): - assert api_key.is_active() - - def test_is_active_false(self, user): - user.active = False - user.save() - api_key = ProjectApiKey.objects.create(user=user) - assert api_key.is_active() is False - - def test_is_staff_false(self, api_key): - assert api_key.is_staff() is False - - def test_is_staff(self, admin): - api_key = ProjectApiKey.objects.create(user=admin) - assert api_key.is_staff() diff --git a/src/tests/project_api_key/test_permission.py b/src/tests/project_api_key/test_permission.py deleted file mode 100644 index 990fc95..0000000 --- a/src/tests/project_api_key/test_permission.py +++ /dev/null @@ -1,161 +0,0 @@ -import pytest -from authentication.models import User -from project_api_key.permissions import (HasProjectAPIKey, - HasStaffProjectAPIKey, check_user_set) -from rest_framework_simplejwt.models import TokenUser - - -@pytest.mark.django_db -def test_check_user_set(user, mocker): - token_user = TokenUser(token="test") - token_user.id = user.id - - request = mocker.Mock() - request.user = token_user - assert isinstance(request.user, TokenUser) - - assert check_user_set(request) - assert isinstance(request.user, User) - - request = mocker.Mock() - request.user = user - assert isinstance(request.user, User) - - assert check_user_set(request) - assert isinstance(request.user, User) - - token_user = TokenUser(token="test") - token_user.id = 10 # non existent user id - - request = mocker.Mock() - request.user = token_user - assert check_user_set(request) is False - assert isinstance(request.user, TokenUser) - - -class TestHasStaffProjectAPIKey: - @property - def perm(self): - return HasStaffProjectAPIKey() - - def test_get_from_header(self, mocker): - request = mocker.Mock() - request.META = { - "HTTP_API_KEY": "test", - } - assert self.perm.get_from_header(request, "HTTP_API_KEY") == "test" - assert self.perm.get_from_header(request, "HTTP_API_SEC_KEY") is None - - @pytest.mark.django_db - def test_validate_apikey_valid(self, mocker, admin_api_key_headers): - request = mocker.Mock() - request.META = admin_api_key_headers - valid, api_obj = self.perm.validate_apikey(request) - assert valid - assert api_obj is not None - - @pytest.mark.django_db - def test_validate_apikey_false(self, mocker, settings): - request = mocker.Mock() - request.META = { - settings.API_KEY_HEADER: "test", - settings.API_SEC_KEY_HEADER: "test", - } - valid, api_obj = self.perm.validate_apikey(request) - assert valid is False - assert api_obj is None - - request.META = { - settings.API_KEY_HEADER: "test", - } - valid, api_obj = self.perm.validate_apikey(request) - assert valid is False - assert api_obj is None - - @pytest.mark.django_db - def test_has_permission_valid( - self, mocker, admin_api_key_headers, admin, user - ): - request = mocker.Mock() - request.META = admin_api_key_headers - request.user = admin - assert self.perm.has_permission(request, None) - - request.user = TokenUser(token="test") - request.user.id = admin.id - assert self.perm.has_permission(request, None) - - request.user = user - assert self.perm.has_permission(request, None) - - @pytest.mark.django_db - def test_has_permission_invalid( - self, mocker, admin_api_key_headers, - admin, basic_api_key_headers - ): - request = mocker.Mock() - request.META = basic_api_key_headers - request.user = admin - assert self.perm.has_permission(request, None) is False - - request.user = TokenUser(token="test") - request.user.id = 10 # non existent user id - request.META = admin_api_key_headers - assert self.perm.has_permission(request, None) is False - - -class TestHasProjectAPIKey: - @property - def perm(self): - return HasProjectAPIKey() - - @pytest.mark.django_db - def test_has_permission_valid( - self, mocker, admin_api_key_headers, - admin, user, basic_api_key_headers - ): - request = mocker.Mock() - request.META = admin_api_key_headers - request.user = admin - assert self.perm.has_permission(request, None) - - request.META = basic_api_key_headers - assert self.perm.has_permission(request, None) - - request.user = TokenUser(token="test") - request.user.id = admin.id - assert self.perm.has_permission(request, None) - - request.user = user - assert self.perm.has_permission(request, None) - - @pytest.mark.django_db - def test_has_permission_invalid( - self, mocker, admin_api_key_headers, - admin, user, basic_api_key_headers - ): - # Test inactive basic keys with both admin and basic user - request = mocker.Mock() - request.META = basic_api_key_headers - user.active = False - user.save() - request.user = admin - assert self.perm.has_permission(request, None) is False - - request.user = user - assert self.perm.has_permission(request, None) is False - - # Test inactive admin keys with both admin and basic user - request.META = admin_api_key_headers - admin.active = False - admin.save() - assert self.perm.has_permission(request, None) is False - - request.user = admin - assert self.perm.has_permission(request, None) is False - - # Test invalid user - request.user = TokenUser(token="test") - request.user.id = 10 # non existent user id - request.META = admin_api_key_headers - assert self.perm.has_permission(request, None) is False diff --git a/src/tests/utils/test_custom_status_code.py b/src/tests/utils/test_custom_status_code.py index 3ab7f79..d0cda84 100644 --- a/src/tests/utils/test_custom_status_code.py +++ b/src/tests/utils/test_custom_status_code.py @@ -48,9 +48,3 @@ def test_http_439_account_deactivated(self): def test_http_440_invalid_signature(self): assert self.code.HTTP_440_INVALID_SIGNATURE == 440 - - def test_http_441_no_business_account(self): - assert self.code.HTTP_441_NO_BUSINESS_ACCOUNT == 441 - - def test_http_442_bad_payment_request(self): - assert self.code.HTTP_442_BAD_PAYMENT_REQUEST == 442 diff --git a/src/tests/utils/test_general.py b/src/tests/utils/test_general.py index dd3a1b5..a92a75a 100644 --- a/src/tests/utils/test_general.py +++ b/src/tests/utils/test_general.py @@ -7,40 +7,25 @@ from django.test import TestCase from rest_framework import serializers -from authentication.models import Profile +from account.models import Profile from tests.models import ModelText from utils.base.general import (Crypthex, add_queryset, capture_output, check_serialized_data, choices_to_dict, compare_hash, convert_list_to_choices, - generate_unique_link_id, get_randint_range, - get_random_secret, get_tokens_for_user, - get_usable_name, invalid_str, merge_querysets, - printt, random_otp, random_text, send_email, - username_gen) + get_randint_range, get_random_secret, + get_tokens_for_user, invalid_str, + merge_querysets, printt, random_otp, + random_text, send_email, username_gen) class GetUniqueNameModel(models.Model): username = models.CharField(max_length=255, unique=True) -@pytest.mark.django_db -def test_get_usable_name(settings): - obj = GetUniqueNameModel() - - username = get_usable_name(Profile) - assert username is not None - assert username.startswith(settings.USERNAME_PREFIX) - obj.username = username - obj.save() - - username = get_usable_name(Profile) - assert username != obj.username - - def test_get_random_secret(settings): computed = get_random_secret() - assert settings.WEBHOOK_SECRET_LENGTH_START <= \ - len(computed) <= settings.WEBHOOK_SECRET_LENGTH_STOP + assert settings.SECRET_LENGTH_START <= \ + len(computed) <= settings.SECRET_LENGTH_STOP @pytest.mark.parametrize( @@ -177,24 +162,6 @@ def func(value): assert output.strip("\n") == "test" -class GenerateUniqueLinkIdModel(models.Model): - link_id = models.CharField(max_length=255, unique=True) - - -@pytest.mark.django_db -def test_generate_unique_link_id(settings): - obj = GenerateUniqueLinkIdModel() - - link_id = generate_unique_link_id(obj) - assert link_id is not None - obj.link_id = link_id - obj.save() - - link_id = generate_unique_link_id(obj) - assert link_id != obj.link_id - assert link_id.startswith(settings.LINK_ID_PREFIX) - - @pytest.mark.parametrize( "value, expected", [ diff --git a/src/tests/utils/test_mixins.py b/src/tests/utils/test_mixins.py index 0f751a4..bdb5aa6 100644 --- a/src/tests/utils/test_mixins.py +++ b/src/tests/utils/test_mixins.py @@ -1,82 +1,6 @@ import pytest from django.db import models -from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_encode - -from utils.base.mixins import (CustomModelViewSet, CustomResponse, - ModelChangeFunc, UidCreatedModel, - ValidateUidb64) -from utils.base.status import StatCode - - -class _UidCreatedModel(UidCreatedModel): - pass - - -@pytest.mark.django_db -def test_uid(): - model = _UidCreatedModel() - model.save() - assert model.uid - assert str(model.uid) == str(model) - - -@pytest.mark.django_db -class TestValidateUidb64: - - class ViewSetOne(ValidateUidb64): - - class Request: - data = {'uidb64': 'test'} - - request = Request() - - def test_validate_uidb64(self, user): - view = self.ViewSetOne() - uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) - view.request.data = {'uidb64': uidb64} - assert view.validate_uid_token() == user - - def test_validate_uidb64_fake_user(self): - view = self.ViewSetOne() - response = view.validate_uid_token() - assert response - assert response.status_code == StatCode.HTTP_435_INVALID_UIDB64 - - def test_validate_uidb64_no_uidb64(self): - view = self.ViewSetOne() - view.request.data = {} - response = view.validate_uid_token() - assert response - assert response.status_code == StatCode.HTTP_435_INVALID_UIDB64 - - -def test_custom_response(): - data = {'test': 'test'} - response = CustomResponse(data, message='test') - assert response.data == data - assert response.message == 'test' - - -class TestCustomModelViewSet: - class ViewOne(CustomModelViewSet): - created_message: str = "Testing" - - class DemoModelOne(models.Model): - pass - - def test_view_one(self): - assert self.ViewOne().get_created_message() == "Testing" - - def test_created_msg_none(self): - self.ViewOne.created_message = None - assert self.ViewOne().get_created_message() == \ - "Object successfully created" - - def test_with_instance(self): - self.ViewOne.created_message = None - assert self.ViewOne().get_created_message(self.DemoModelOne()) == \ - "DemoModelOne successfully created" +from utils.base.mixins import ModelChangeFunc @pytest.mark.django_db @@ -111,19 +35,6 @@ def check_field(self): 'other': check_field, } - class MultiModel(ModelChangeFunc): - field = models.CharField(max_length=100) - other = models.CharField(max_length=100) - check = [] - - def check_field(self): - self.check.append() - - monitor_change = { - 'field': check_field, - 'other': check_field, - } - def test_monitor_change_fields(self): assert self._Model().monitor_change_fields == ['field'] @@ -131,17 +42,17 @@ def test_monitor_change_fields_no_model_check(self): assert self.NoModelCheck().monitor_change_fields == [] def test_monitor_change_funcs(self): - assert self._Model().monitor_change_funcs == [ + assert self._Model().monitor_change_funcs == set([ self._Model.check_field, - ] + ]) def test_monitor_change_funcs_no_model_check(self): - assert self.NoModelCheck().monitor_change_funcs == [] + assert self.NoModelCheck().monitor_change_funcs == set() def test_monitor_change_funcs_similar_model_check(self): - assert self.SimilarModelCheck().monitor_change_funcs == [ + assert self.SimilarModelCheck().monitor_change_funcs == set([ self.SimilarModelCheck.check_field, - ] + ]) def test_get_clone_field(self): assert self._Model().get_clone_field('field') == '__field' diff --git a/src/tests/utils/test_permission.py b/src/tests/utils/test_permission.py index dfa2ba6..67317b7 100644 --- a/src/tests/utils/test_permission.py +++ b/src/tests/utils/test_permission.py @@ -27,63 +27,3 @@ def test_has_permission( mock_request.user.admin = admin assert self.perm.has_permission(mock_request, None) is expected - - -@pytest.mark.django_db -class TestLevelOne: - - @property - def perm(self): - return permissions.LevelOne() - - def test_has_permission_staff( - self, mocker, admin, admin_api_key_headers - ): - mock_request = mocker.Mock() - mock_request.user = admin - mock_request.META = admin_api_key_headers - - assert self.perm.has_permission(mock_request, None) - - def test_has_permission_auth_admin( - self, mocker, basic_api_key_headers - ): - mock_request = mocker.Mock() - mock_request.user = mocker.Mock() - mock_request.META = basic_api_key_headers - - mock_request.user.is_authenticated = True - mock_request.user.staff = False - mock_request.user.admin = True - - assert self.perm.has_permission(mock_request, None) - - -@pytest.mark.django_db -class TestLevelTwo: - - @property - def perm(self): - return permissions.LevelTwo() - - def test_has_permission_staff( - self, mocker, admin, admin_api_key_headers - ): - mock_request = mocker.Mock() - mock_request.user = mocker.Mock() - mock_request.META = admin_api_key_headers - mock_request.user.is_authenticated = True - assert self.perm.has_permission(mock_request, None) - - def test_has_permission_auth_admin( - self, mocker, basic_api_key_headers - ): - mock_request = mocker.Mock() - mock_request.user = mocker.Mock() - mock_request.META = basic_api_key_headers - - mock_request.user.is_authenticated = True - mock_request.user.staff = False - mock_request.user.admin = True - - assert self.perm.has_permission(mock_request, None) diff --git a/src/tests/utils/test_routers.py b/src/tests/utils/test_routers.py deleted file mode 100644 index 133cd82..0000000 --- a/src/tests/utils/test_routers.py +++ /dev/null @@ -1,96 +0,0 @@ -from rest_framework.viewsets import ModelViewSet -from rest_framework.decorators import action -from utils.base.routers import CustomDefaultRouter, CustomRouterNoLookup - - -class BookViewSet(ModelViewSet): - - @action(detail=False) - def search(self, request): - pass - - @action(detail=True) - def read(self, request, pk=None): - pass - - -def get_pattern(route): - return route.pattern.regex.pattern - - -class TestCustomDefaultRouter: - def test_custom_default_router(self): - router = CustomDefaultRouter() - router.register('books', BookViewSet, basename='books') - routes = router.urls - - urls = router.get_routes(BookViewSet) - - assert len(routes) == 7 - - assert routes[0].name == 'books-list' - assert get_pattern(routes[0]) == r'^books/$' - assert urls[0].mapping == {'get': 'list'} - - assert routes[1].name == 'books-search' - assert get_pattern(routes[1]) == r'^books/search/$' - assert urls[1].mapping == {'get': 'search'} - - assert routes[2].name == 'books-create' - assert get_pattern(routes[2]) == r'^books/create/$' - assert urls[2].mapping == {'post': 'create'} - - assert routes[3].name == 'books-detail' - assert get_pattern(routes[3]) == r'^books/detail/(?P[^/.]+)/$' - assert urls[3].mapping == {'get': 'retrieve'} - - assert routes[4].name == 'books-update' - assert get_pattern(routes[4]) == r'^books/update/(?P[^/.]+)/$' - assert urls[4].mapping == {'put': 'update', 'patch': 'partial_update'} - - assert routes[5].name == 'books-read' - assert get_pattern(routes[5]) == r'^books/(?P[^/.]+)/read/$' - assert urls[5].mapping == {'get': 'read'} - - assert routes[6].name == 'books-delete' - assert get_pattern(routes[6]) == r'^books/delete/(?P[^/.]+)/$' - assert urls[6].mapping == {'delete': 'destroy'} - - -class TestCustomRouterNoLookup: - def test_custom_router_no_lookup(self): - router = CustomRouterNoLookup() - router.register('books', BookViewSet, basename='books') - routes = router.urls - - urls = router.get_routes(BookViewSet) - - assert len(routes) == 7 - - assert routes[0].name == 'books-list' - assert get_pattern(routes[0]) == r'^books/$' - assert urls[0].mapping == {'get': 'list'} - - assert routes[1].name == 'books-search' - assert get_pattern(routes[1]) == r'^books/search/$' - assert urls[1].mapping == {'get': 'search'} - - assert routes[2].name == 'books-create' - assert get_pattern(routes[2]) == r'^books/create/$' - assert urls[2].mapping == {'post': 'create'} - - assert routes[3].name == 'books-detail' - assert get_pattern(routes[3]) == r'^books/detail/$' - assert urls[3].mapping == {'get': 'retrieve'} - - assert routes[4].name == 'books-update' - assert get_pattern(routes[4]) == r'^books/update/$' - assert urls[4].mapping == {'put': 'update', 'patch': 'partial_update'} - - assert routes[5].name == 'books-read' - assert get_pattern(routes[5]) == r'^books/read/$' - assert urls[5].mapping == {'get': 'read'} - - assert routes[6].name == 'books-delete' - assert get_pattern(routes[6]) == r'^books/delete/$' - assert urls[6].mapping == {'delete': 'destroy'} diff --git a/src/tox.ini b/src/tox.ini index 41b04d2..a713fbb 100644 --- a/src/tox.ini +++ b/src/tox.ini @@ -3,6 +3,21 @@ omit = */tests/* manage.py config/* */tests* + */migrations/* + */apps.py + */admin.py + utils/base/routers.py + utils/base/schema.py + utils/base/_types.py + */urls.py + [coverage:report] -show_missing = true \ No newline at end of file +show_missing = true +exclude_lines = + pragma: no cover + def __repr__ + def __str__ + if self.debug + if settings.DEBUG + raise NotImplementedError diff --git a/src/utils/base/constants.py b/src/utils/base/constants.py new file mode 100644 index 0000000..d4b5f16 --- /dev/null +++ b/src/utils/base/constants.py @@ -0,0 +1,3 @@ +from django.contrib.auth import get_user_model + +User = get_user_model() diff --git a/src/utils/base/email.py b/src/utils/base/email.py new file mode 100644 index 0000000..2def27b --- /dev/null +++ b/src/utils/base/email.py @@ -0,0 +1,33 @@ +from django.contrib.sites.shortcuts import get_current_site +from django.http import HttpRequest +from django.template.loader import render_to_string +from django.conf import settings + + +def render_email_message(request: HttpRequest, template: str, context: dict = None): + """Render email message using template and context + + :param request: http request + :type request: HttpRequest + :param template: email template path + :type template: str + :param context: context to be \ +passed to template. Defaults to None. + :type context: dict, optional + :return: message to be sent + :rtype: str + """ + + site = get_current_site(request) + + default_data = { + "request": request, + "domain": site.domain, + 'from': settings.DEFAULT_FROM_EMAIL, + "APP_NAME": settings.APP_NAME, + } + + context.update(default_data) + + message = render_to_string(template, context) + return message diff --git a/src/utils/base/exceptions.py b/src/utils/base/exceptions.py index 382239d..a0fa4d7 100644 --- a/src/utils/base/exceptions.py +++ b/src/utils/base/exceptions.py @@ -1,5 +1,6 @@ -from rest_framework.exceptions import APIException, ParseError +from rest_framework.exceptions import ParseError # pragma: no cover -class QueryParseError(ParseError): + +class QueryParseError(ParseError): # pragma: no cover default_detail = 'Malformed or Incomplete query data' - default_code = 'baq_query_data' \ No newline at end of file + default_code = 'baq_query_data' diff --git a/src/utils/base/fields.py b/src/utils/base/fields.py deleted file mode 100644 index d30ab88..0000000 --- a/src/utils/base/fields.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Custom fields to be used across all packages -""" - -from django.core import checks -from django.db import models -from model_bakery import baker -from utils.base.general import create_unique_tracking_id - - -class ModelBakeryGenerator: - - @classmethod - def register(cls, baker: baker): - baker.generators.add(cls, cls.baker_gen_func) - - @classmethod - def baker_gen_func(cls): - """Function to generate values for model bakery to use""" - raise NotImplementedError - - -class TrackingCodeField(ModelBakeryGenerator, models.CharField): - """ - Automatically generates a tracking code that can be - prefixed. - prefix_max_length is 10 - """ - - prefix_max_length = 10 - default_prefix = "UID" - - @classmethod - def baker_gen_func(cls): - return create_unique_tracking_id(prefix=cls.default_prefix) - - def __init__(self, *args, prefix: str = None, max_length: int = 225, **kwargs): - kwargs["max_length"] = max_length - kwargs["editable"] = False - kwargs["unique"] = True - - if prefix is None: - prefix = self.default_prefix - - self.prefix = prefix - self.max_length = max_length - super().__init__(*args, **kwargs) - - def _check_prefix_attribute(self): - if not isinstance(self.prefix, str): - return [ - checks.Error( - "'prefix' must be a string.", - obj=self, - id='tripapi.E001', - ) - ] - elif len(self.prefix) > self.prefix_max_length: - return [ - checks.Error( - f"'prefix' length must not be greater \ -than {self.prefix_max_length}.", - obj=self, - id='tripapi.E002', - ) - ] - else: - return [] - - def check(self, **kwargs): - return [ - *super().check(**kwargs), - *self._check_prefix_attribute(), - ] - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - del kwargs["editable"] - del kwargs["unique"] - - if self.prefix != self.default_prefix: - kwargs['prefix'] = self.prefix - kwargs["max_length"] = self.max_length - - return name, path, args, kwargs - - def pre_save(self, model_instance, add: bool): - if add: - value = create_unique_tracking_id(prefix=self.prefix) - setattr(model_instance, self.attname, value) - return value - return super().pre_save(model_instance, add) diff --git a/src/utils/base/general.py b/src/utils/base/general.py index b9dfc66..00ae989 100644 --- a/src/utils/base/general.py +++ b/src/utils/base/general.py @@ -1,10 +1,10 @@ -from functools import reduce import hmac import os import secrets import string import sys from contextlib import contextmanager +from functools import reduce from io import StringIO from typing import Callable, Generator, List @@ -15,10 +15,10 @@ from django.template.defaultfilters import slugify from django.utils.crypto import RANDOM_STRING_CHARS, get_random_string from django.utils.encoding import force_bytes -from drf_yasg.utils import swagger_auto_schema -from rest_framework_simplejwt.tokens import RefreshToken from drf_yasg.openapi import Schema +from drf_yasg.utils import swagger_auto_schema from rest_framework.serializers import Serializer +from rest_framework_simplejwt.tokens import RefreshToken from .logger import err_logger, logger # noqa @@ -185,7 +185,7 @@ def send_email(email, subject, message, fail=True): val = send_mail( subject=subject, message=message, - html_message=message, rom_email=settings.DEFAULT_FROM_EMAIL, + html_message=message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[email], fail_silently=fail) return True if val else False @@ -324,34 +324,13 @@ def get_random_secret() -> str: :rtype: str """ length = get_randint_range( - settings.WEBHOOK_SECRET_LENGTH_START, - settings.WEBHOOK_SECRET_LENGTH_STOP + settings.SECRET_LENGTH_START, + settings.SECRET_LENGTH_STOP ) allowed_chars = RANDOM_STRING_CHARS + '.-_!$;*#@' return get_random_string(length, allowed_chars) -def get_usable_name(profile, name: str = None) -> str: - """ - Get a unique username for newly created users - - :param profile: Profile model - :type profile: authentication.models.Profile - :param name: last generated username, defaults to None - :type name: str, optional - :return: unique username - :rtype: str - """ - if not name: - name = username_gen(5) - - # Check if the name exists in the Profile table - exists = profile.objects.filter(username=name).exists() - if exists: - return get_usable_name(profile, username_gen(5)) - return name - - @contextmanager def capture_output(func: Callable[..., None]) -> Generator[str, None, None]: """Context manager to capture diff --git a/src/utils/base/logger.py b/src/utils/base/logger.py index cb4ee57..2fa855e 100644 --- a/src/utils/base/logger.py +++ b/src/utils/base/logger.py @@ -1,4 +1,5 @@ import logging + # Create the logger and set the logging level logger = logging.getLogger('basic') err_logger = logging.getLogger('basic.error') diff --git a/src/utils/base/mixins.py b/src/utils/base/mixins.py index e8f3aff..ca824e6 100644 --- a/src/utils/base/mixins.py +++ b/src/utils/base/mixins.py @@ -2,40 +2,30 @@ Mixins to be used across all packages """ -from typing import Callable, List +from typing import Callable, List, Iterable from django.contrib import admin from django.db import models from django.db.models.query import QuerySet from rest_framework import mixins, viewsets from rest_framework.response import Response -from utils.base.fields import TrackingCodeField -class BaseModelTracker(models.Model): +class ModelChangeFunc(models.Model): """ - Abstract model for creating tracking_code for models. - Prefix for making tracking code diffent - on each model if needed + Abstract model to be used to monitor changes to a model + + monitor_change: dict = { + field: function + } + Field is the field to monitor, and the function is the function to run + when the field is changed. The function must take in the model as the + only argument. """ - code_prefix = 'UID' - - tracking_code = TrackingCodeField(prefix=code_prefix, max_length=60) - - class Meta: - abstract = True - - -class ModelChangeFunc(models.Model): - class Meta: abstract = True - # Setup update func - """ - Key and Update function to run when something changes - """ monitor_change: dict = None @property @@ -45,10 +35,10 @@ def monitor_change_fields(self) -> list: return [] @property - def monitor_change_funcs(self) -> List[Callable[..., None]]: + def monitor_change_funcs(self) -> Iterable[Callable[..., None]]: if self.monitor_change: return set([func for _, func in self.monitor_change.items()]) - return tuple() + return set() def get_clone_field(self, name: str) -> str: return f"__{name}" @@ -59,6 +49,12 @@ def get_attr(self, field: str): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Set clone fields value to the initial value + for field in self.monitor_change_fields: + clone_field = self.get_clone_field(field) + normal_value = self.get_attr(field) + setattr(self, clone_field, normal_value) + def call_updates(self): """Forcefully call all update functions""" for function in self.monitor_change_funcs: @@ -105,6 +101,7 @@ class ListMixinUtils(object): Implementation of a get reponse passed a queryset manually, to be used with list views sets with different list urls. """ + def get_with_queryset(self, queryset: QuerySet): page = self.paginate_queryset(queryset) if page is not None: diff --git a/src/utils/base/permissions.py b/src/utils/base/permissions.py index be954f6..091ef7c 100644 --- a/src/utils/base/permissions.py +++ b/src/utils/base/permissions.py @@ -1,15 +1,35 @@ from django.contrib.auth import get_user_model +from django.http import HttpRequest from rest_framework.permissions import BasePermission +from rest_framework_simplejwt.models import TokenUser -from project_api_key.permissions import check_user_set -User = get_user_model() +class IsAuthenticated(BasePermission): + """ + This is a permission class to validate + that the user is authenticated + """ + def set_request_set(self, request: HttpRequest): + """ + if the request is a TokenUser, set the request.user to the + actual user object + """ -class IsAuthenticatedAdmin(BasePermission): + User = get_user_model() + if isinstance(request.user, TokenUser): + user = User.objects.get(id=request.user.id) + request.user = user + + def has_permission(self, request: HttpRequest, view): + self.set_request_set(request) + return request.user.is_active + + +class IsAuthenticatedAdmin(IsAuthenticated): """Validates logged in user is an admin""" - def has_permission(self, request, view): - if check_user_set(request): + + def has_permission(self, request: HttpRequest, view): + if super().has_permission(request, view): if request.user.is_authenticated: - return request.user.staff or request.user.admin - return False + return request.user.is_personnel diff --git a/src/utils/base/routers.py b/src/utils/base/routers.py index 921a8c9..c2d3a4c 100644 --- a/src/utils/base/routers.py +++ b/src/utils/base/routers.py @@ -117,4 +117,3 @@ class CustomRouterNoLookup(DefaultRouter): initkwargs={'suffix': 'Delete'} ), ] - diff --git a/src/utils/base/schema.py b/src/utils/base/schema.py index 044eb13..ac073b0 100644 --- a/src/utils/base/schema.py +++ b/src/utils/base/schema.py @@ -1,7 +1,6 @@ from drf_yasg import openapi from drf_yasg.inspectors import SwaggerAutoSchema from drf_yasg.openapi import Schema - from rest_framework.status import is_success @@ -68,6 +67,26 @@ def get_responses(self): except AttributeError: pass + # Add 400 response for all methods + data['400'] = openapi.Response( + description='Bad request', + schema=self.wrap_schema_error( + Schema( + type='object', + description='Error message key and value', + properties={ + 'key': Schema( + type='array', + items=Schema( + type='string', + description='Error messages' + ) + ) + } + ) + ) + ) + return openapi.Responses( responses=data ) diff --git a/src/utils/base/validators.py b/src/utils/base/validators.py index 5c16f32..420e6ba 100644 --- a/src/utils/base/validators.py +++ b/src/utils/base/validators.py @@ -3,6 +3,7 @@ from django.core.validators import RegexValidator from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import ValidationError + from .general import invalid_str