[0713] user app

nikevapormax·2022년 7월 13일
0

TIL

목록 보기
72/116
post-custom-banner

iPark Project

정규표현식

  • 이번 프로젝트에서는 회원가입, 아이디 찾기, 비밀번호 변경에서 정규표현식을 적용했다.
  • 회원가입에서 정규표현식을 custom validator를 만들어 사용하면서 처음에 사용자가 입력한 값이 틀렸다면 에러 메세지를 통해 올바른 양식을 입력하도록 유도할 수 있었다.

회원가입

  • user/serializers.py
import re
from rest_framework import serializers

from user.models import User as UserModel


EMAIL = ("@naver.com", "@gmail.com", "@kakao.com", "@daum.net", "@nate.com", "@outlook.com")


class UserSerializer(serializers.ModelSerializer):
    
    class Meta:
        model = UserModel
        fields = ["username", "password", "fullname", "email", "phone", "birthday", "region", "join_date"]
        
        extra_kwargs = {
            "password": {"write_only": True},
        }
        
    def validate(self, data):
        print(data)
        correct_email = re.compile("^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
        correct_phone = re.compile("(010)-\d{4}-\d{4}")
        correct_password = re.compile("^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$")
        correct_birthday = re.compile("\d{4}-\d{2}-\d{2}$")
                
        email_input = correct_email.match(data.get("email", ""))
        phone_input = correct_phone.match(data.get("phone", ""))
        password_input = correct_password.match(data.get("password", ""))
        birthday_input = correct_birthday.match(str(data.get("birthday", "")))
        
        if data.get("username"):
            if not len(data.get("username", "")) >= 6:
                raise serializers.ValidationError(
                    detail={"error": "username의 길이는 6자리 이상이어야 합니다."})
                
        if email_input == None:
            raise serializers.ValidationError(
                detail={"error": "이메일 형식이 올바르지 않습니다."})
        else:
            if not data.get("email", "").endswith(EMAIL):
                raise serializers.ValidationError(
                    detail={"error": "네이버, 구글, 카카오, 다음, 네이트, 아웃룩 이메일만 가입할 수 있습니다."})
        
        if password_input == None:
            raise serializers.ValidationError(
                detail={"error": "비밀번호는 8 자리 이상이며 최소 하나 이상의 영문자, 숫자, 특수문자가 필요합니다."})
        
        if phone_input == None:
            raise serializers.ValidationError(
                detail={"error": "전화번호는 010-0000-0000 형식으로 작성해주시기 바랍니다."})
            
        if birthday_input == None:
            raise serializers.ValidationError(
                detail={"error": "생년월일은 YYYY-MM-DD 형식으로 작성해주시기 바랍니다."})
        
        return data
  • 정규표현식은 만약 내가 정해놓은 correct_email 등과 다른 결과가 들어오면 None 값을 반환한다.(문자열 x)
  • 이메일의 경우 endswith를 사용해 내가 정해놓은 도메인을 가진 이메일이 아니라면 회원가입을 하지 못하도록 하였다. 물론 다양성 측면에서 한계가 있을 수 있지만, 현재 수준으로 이메일을 다루기에 적합다하 판단하여 위와 같이 상수 EMAIL을 작성해 적용하였다.
  • 한 가지 주의할 점은 birthday다. birthday는 모델을 만들 때 DateField로 지정해 주었다. 그렇기 때문에 해당 값은 문자열로 들어오지 않는다.
    • 따라서 문자열로 바꿔주지 않으면 TypeError: expected string or bytes-like object라는 에러 메세지를 만나게 될 것이다.
    • 당황하지 않고 str()로 감싸주면 문제가 해결된다.

아이디 찾기

  • user/views.py
class FindUserInfoView(APIView):
    # 아이디 찾기
    def post(self, request):
        correct_email = re.compile("^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
        correct_phone = re.compile("\d{3}-\d{4}-\d{4}")
        
        email_input = correct_email.match(request.data["email"])
        phone_input = correct_phone.match(request.data["phone"])
        
        if email_input == None or phone_input == None:
            return Response({"message": "이메일 혹은 핸드폰 번호 양식이 올바르지 않습니다."}, status=status.HTTP_400_BAD_REQUEST)
        else:
            searched_username = UserModel.objects.get(Q(email=request.data["email"]) & Q(phone=request.data["phone"])).username
        
        if searched_username:
            return Response({"username" : searched_username}, status=status.HTTP_200_OK)
  • 사용자가 본인의 아이디를 잊어버렸을 때 확인하는 데이터로 나는 emailphone을 사용했다.
  • 그 이유는 둘 다 unique 값이기 때문에 각 회원마다의 고유한 정보이기 때문이다.
  • 이 정보들은 사용자가 다시 입력해야 하기 때문에 정규표현식을 한 번 더 사용해 주어 사용자가 잘못된 양식을 입력하면 다음 페이지로 넘어가지 못하도록 막았다.
  • 사용자가 입력한 값들이 전부 맞을 시 쿼리를 날려야 하기 때문에 Q를 사용해 주었다. 해당 값들과 일치하는 데이터를 search_username에 담아주어 보내주는 방식으로 작성했다.

비밀번호 변경

  • user/views.py
class AlterPasswordView(APIView):
    # 비밀번호를 변경할 자격이 있는지 확인
    def post(self, request):
        """
        1. 비밀번호를 변경할 사용자의 username, email을 입력받는다.
        2. 해당 값을 통해 비밀번호를 변경할 user를 찾아준다. 
        3. 만약 user가 존재한다면 user 정보를 비밀번호 수정 페이지에서도 알 수 있도록 넘겨준다.
        4. user가 존재하지 않는다면 "존재하지 않는 사용자입니다." 라는 메세지를 반환한다.
        """
        
        correct_email = re.compile("^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
        email_input = correct_email.match(request.data["email"])
        
        if email_input == None:
            return Response({"message": "이메일 형식에 맞게 작성해주세요."})
        else:
            user = UserModel.objects.get(Q(username=request.data["username"]) & Q(email=request.data["email"]))
            if user:
                return Response({"message": "비밀번호 변경 페이지로 이동합니다."}, status=status.HTTP_200_OK)
            
            return Response({"message": "존재하지 않는 사용자입니다."}, status=status.HTTP_400_BAD_REQUEST)
    
    # 비밀번호 변경
    def put(self, request):
        """
        1. 사용자의 정보를 그대로 가져온다. 
        2. 새롭게 세팅할 비밀번호와 중복 확인용 비밀번호를 받는다. 
        3. 이 두 비밀번호가 정규표현식을 통과하고 일치한다면, UserSerializer에 request.data를 보내 custom updator를 통해 비밀번호를 update해준다.
        """
        correct_password = re.compile("^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$")
        
        first_password_input = correct_password.match(request.data["new_password"])
        second_password_input = correct_password.match(request.data["rewrite_password"])
        
        if first_password_input == None or second_password_input == None:
            return Response({"message": "비밀번호를 제대로 입력해주세요."}, status=status.HTTP_400_BAD_REQUEST)
        else:
            if request.data["new_password"] == request.data["rewrite_password"]:
                user = UserModel.objects.get(Q(username=request.data["username"]) & Q(email=request.data["email"]))
                request.data["password"] = request.data["new_password"]
                serializer = UserSerializer(user, data=request.data, partial=True)
                if serializer.is_valid():
                    serializer.save()
                    return Response({"message": "비밀번호 변경이 완료되었습니다! 다시 로그인해주세요."}, status=status.HTTP_200_OK)
            
            return Response({"message": "두 비밀번호가 일치하지 않습니다."})
  • 원래는 이메일 인증을 해보려 했는데, 구글 이메일 아이디와 비밀번호가 맞는데도 에러가 나 일단 다른 기능을 구현하기 위해 해당 방법을 선택했다. 추후 이메일 인증을 재도전할 예정이다.
  • 내가 생각한 로직으로는
    • 사용자가 비밀번호를 변경할 수 있는 권한이 있는지 알아내기 위해 사용자의 아이디와 이메일을 입력하도록 했다.
      • 유명한 사이트들은 이메일 또는 핸드폰 번호를 받는데, 나의 모델에서는 핸드폰 번호는 고유값이 아니기 때문에 이메일만 받자는 선택을 했다.
    • 이메일을 받고 해당 이메일을 가진 사용자가 있다면 프론트에 있는 비밀번호 변경(아직 자세한 네이밍은 프론트를 짜지 않아 정하지 않음) 버튼을 누르게 되면 비밀번호 변경 페이지로 보내주려고 한다.
    • 해당 페이지로 넘어가면서 나는 사용자의 아이디와 이메일 데이터를 가져가 이곳에서도 user를 찾고, 수정이므로 해당 user 값을 instance로 넘겨줄 생각이었다.
  • 현재 request.data에 들어있는 값들이 custom validator를 통과하기에 부족한 것 같고, 실제로 is_valid()를 넘어가지 못하고 있다.
  • 해당 부분에 대한 수정이 필요하며, 현재 진행중이다.

테스트 코드 작성

  • user app에서는 비밀번호 변경을 제외하고는 모두 작성해 보았다.
  • 내용이 틀린 부분이 많겠지만, 테스트 결과는 모두 통과이다. 5개 정도에 0.369s면 짧은 시간은 아니지만 1초를 안넘겨서 다행이라 생각한다.
  • user/tests.py
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status

from user.models import User as UserModel
from user.models import Region as RegionModel


# 회원가입 테스트
class UserRegistrationTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.region = RegionModel.objects.bulk_create([RegionModel(region_name="강남구"),
                                                      RegionModel(region_name="강동구"),
                                                      RegionModel(region_name="강북구")])

    def test_registration(self):
        url = reverse("user_view")
        user_data = {
            "username" : "user10",
            "password" : "1010abc!",
            "fullname" : "user10",
            "email" : "user10@gmail.com",
            "phone" : "010-1010-1010",
            "birthday" : "2022-07-13",
            "region" : 2
        }
        
        response = self.client.post(url, user_data)

        self.assertEqual(response.status_code, 200)
        

# 로그인 테스트
class UserLoginTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user_data = {"username" : "user10", "password" : "1010abc!"}
        cls.user = UserModel.objects.create_user("user10", "1010abc!")
        
    def test_login(self):
        url = reverse("token_obtain_pair")
        user_data = {
            "username" : "user10",
            "password" : "1010abc!"
        }
        
        response = self.client.post(url, user_data)
        
        self.assertEqual(response.status_code, 200)
        
        
# 회원정보 수정 및 회원탈퇴 테스트
class UserInfoModifyDeleteTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.region = RegionModel.objects.bulk_create([RegionModel(region_name="강남구"),
                                                      RegionModel(region_name="강동구"),
                                                      RegionModel(region_name="강북구")])
        
        cls.user = UserModel.objects.create_user("user10", "1010abc!")
        cls.login_data = {"username": "user10", "password" : "1010abc!"}
        
    def setUp(self):
        self.access_token = self.client.post(reverse("token_obtain_pair"), self.login_data).data["access"]
    
    # 회원정보 수정 테스트
    def test_modify_user_info(self):
        url = reverse("user_view")
        data_for_change = {
            "password" : "2020abc!",
            "fullname" : "user20",
            "email" : "user20@gmail.com",
            "phone" : "010-1010-1010",
            "birthday" : "2022-07-13",
            "region" : 3
        }
        
        response = self.client.put(
            path=url, 
            data=data_for_change,
            HTTP_AUTHORIZATION=f"Bearer {self.access_token}"
        )
        
        self.assertEqual(response.status_code, 200)
    
    # 회원탈퇴 테스트
    def test_delete_user(self):
        url = reverse("user_view")
        
        response = self.client.delete(url, HTTP_AUTHORIZATION=f"Bearer {self.access_token}")
        
        self.assertEqual(response.data["message"], "회원탈퇴 성공")
        
        
# 아이디 찾기 테스트
class SearchUsernameTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        user_data = {
            "username" : "user10",
            "password" : "1010abc!",
            "fullname" : "user10",
            "email" : "user10@gmail.com",
            "phone" : "010-1010-1010",
            "birthday" : "2022-07-13",
        }
        cls.user = UserModel.objects.create(**user_data)
        
    def test_search_username(self):
        url = reverse("myid_view")
        data = {
            "email" : "user10@gmail.com",
            "phone" : "010-1010-1010"
        }
        
        response = self.client.post(url, data)
        
        self.assertEqual(response.data["username"], "user10")
  • 처음에 region 데이터를 만들 때 아무생각없이 그냥 create를 사용해 테스트용 db를 만들었었다. 그런데 3개나 등록했는데 pk 값으로 1 밖에 받아오지 못해 짜증나 구글링을 했더니 bulk_create라는 좋은 친구가 있었다.
  • 이것을 사용해 여러 개의 db를 생성할 수 있었다.
  • setUpTestData()를 사용해 TestCase 자체가 끝날 때까지 데이터가 유지되도록 해보았다. 해당 코드의 대다수는 사실 TestCase 안에 method가 하나 정도 밖에 없어서 setUp()을 사용해도 될 것같긴 하다.
    • 이 부분에 대한 효율성은 알아보는 것이 좋을 것 같다고 생각한다.
  • status code를 확인하는 것 이외에도 데이터 비교, 메세지 비교 등을 사용해 결과 확인의 폭을 넓히려 노력했다.
  • 나는 Response를 할 때 메세지도 쓰긴 하지만 데이터를 더 많이 넘겨주는 경향이 있어 대다수 status code를 사용했다. 이 부분도 수정하려 노력할 것이다.
profile
https://github.com/nikevapormax
post-custom-banner

0개의 댓글