선발대

[원티드] 2주차 과제: 페이히어 / 가계부 내역 CRUD, 테스트 코드 본문

원티드/기업 과제

[원티드] 2주차 과제: 페이히어 / 가계부 내역 CRUD, 테스트 코드

신선한 스타트 2022. 7. 13. 22:19

과제 진행기간

 

  • 2022년 7월 4일(월) ~ 2022년 7월 8일(금)

 

과제 요구사항

 

  • 고객은 이메일, 비밀번호 입력을 통해 회원가입을 할 수 있음
  • 고객은 로그인 이후 가계부 관련 아래의 행동을 할 수 있음.

 

  • CREATE: 가계부에 오늘 사용한 돈의 금액, 관련 메모를 남길 수 있음
  • UPDATE: 가계부에서 수정을 원하는 내역은 금액, 메모를 수정할 수 있음
  • DELETE: 가계부에서 원하는 내역은 삭제할 수 있음
  • DELETE_FLAG: 삭제한 내역은 언제든지 다시 복구할 수 있음
  • LIST: 가계부에서 이제까지 기록한 가계부 리스트를 볼 수 있음
  • DETAIL: 가계부의 상세한 세부 내역을 볼 수 있음

 

  • 고객은 회원 가입 이후, 로그인, 로그아웃 할 수 있음
  • 로그인하지 않은 고객은 가계부 내역에 대한 접근 제한 처리가 되어야 함.

 

기능구현 목표

 

  • 회원가입 구현
  • 로그인, 로그아웃 구현
  • 가계부 Create(POST) 구현
  • 가계부 Read(GET) 구현
  • 가계부 Update(PATCH) 구현
  • 가계부 Delete 구현(Delete/Delete_Flag)
  • 가계부 List 기능 구현(사용 중인 가계부/삭제된 가계부)
  • 가계부 Detail 기능 구현
  • 사용자 권한 처리 구현(JWT를 활용한 Permission 설정)
  • JWT(DRF-SimpleJwt) 활용
  • 배포(AWS & Docker(Nginx+gunicorn+MySQL))
  • Swagger를 활용한 API 기능 문서화

 

ERD

 

ERDCloud를 이용했다.

 

API 명세서

 

내가 맡은 건 가계부 내역 CRUD

 


 

공부한 내용

 

  • 이번에는 가계부 내역의 CRUD를 만드는 파트를 맡았다.
  • 가계부는 여러 종류가 있다. (예: 1월 가계부, 2월 가계부, ...)
  • 그 가계부마다 여러 세부 내역들이 있는데, 내가 맡은 파트가 이런 내역의 CRUD를 구현하는 것이었다.
  • 테스트 코드도 동시에 작성해야 했기 때문에 부지런히 해야 했다.
  • 서로 같은 views.py, serializers.py를 작성하는 경우가 종종 있어 충돌도 많았다.
  • 그래서 각 앱마다 views, serializers, tests 폴더를 만들고 그 안에 각자의 파일을 작성하기로 했다.

 

작성한 코드

 

 

01. account_books/urls.py

from django.urls import path

from account_books.views.account_book_views import AccountBookView
from account_books.views.account_category_views import AcoountCategoryView

from .views.account_book_detail_views import AccountBookDetailView

app_name = 'account-book'


urlpatterns = [
    path('account_category', AcoountCategoryView.as_view()),
    path('account_category/<int:account_category_id>', AcoountCategoryView.as_view()),
    path('account_category/toggle_active/<int:account_category_id>', AcoountCategoryView.toggle_active),
    path('account-books', AccountBookView.as_view()),
    path('account-books/<int:book_id>', AccountBookView.as_view()),
    path('account-books/toggle_delete/<int:book_id>', AccountBookView.toggle_active),
    path('account-books/deleted_list', AccountBookView.deleted_list),
    path('account-books/toggle_delete/<int:book_id>', AccountBookView.deleted_patch),
    path('account-books/<int:book_id>/accounts', AccountBookDetailView.as_view(), name='book_details'),
    path('account-books/<int:book_id>/accounts/<int:accounts_id>', AccountBookDetailView.as_view(), name='book_details'),
    path('account-books/<int:book_id>/accounts/detail/<int:accounts_id>', AccountBookDetailView.detail, name='book_detail',),
    path('account-books/<int:book_id>/accounts/<int:accounts_id>/toggle_active', AccountBookDetailView.toggle_active, name='book_details_deleted',),
]

 

 

02. account_books/views/account_book_detail_views.py

 

  • 가계부 내역 전체 리스트
  • 가계부 내역 생성
  • 가계부 내역 수정
  • 가계부 내역 상세조회
  • 가계부 내역 삭제, 복구: delete_flag를 이용해서 정말 삭제하는 게 아니라, 상태만 변경하였다.
from account_books.models import AccountBook, AccountCategory, AccountDetail
from account_books.serializers import AccountDetailPostSerializer, AccountDetailSerializer
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView


class AccountBookDetailView(APIView):

    '''가계부 내역 구현 View

    writer : 나
    date : 2022-07-08

    가계부 내역의 CRUD 구현
    GET: 가계부 내역 전체 리스트
    POST: 가계부 내역 생성
    PATCH: 가계부 내역 수정
    detail.GET: 가계부 내역 상세조회
    toggle_active.PATCH: 가계부 내역 삭제, 복구
    '''

    serializer_class = AccountDetailSerializer
    permission_classes = [IsAuthenticated]

    # 가계부 내역 전체 리스트: 가계부 id를 받고, 해당 가계부의 전체 내역을 보여줍니다. + 쿼리스트링으로 delete_flag 여부에 따른 리스트 필터링 가능.
    @swagger_auto_schema(responses={200: AccountDetailSerializer})
    def get(self, request, book_id):
        account_book = get_object_or_404(AccountBook, id=book_id, user=request.user.id)
        account_details = account_book.account_details.filter(delete_flag=request.GET.get('deleted', False))
        serializer = AccountDetailSerializer(account_details, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

    # 가계부 내역 생성: 가계부 id를 받고, 해당 가계부에서 내역을 작성합니다. + 해당하는 카테고리가 없을 경우 에러가 발생합니다.
    @swagger_auto_schema(request_body=AccountDetailPostSerializer, responses={200: AccountDetailPostSerializer})
    def post(self, request, book_id):

        # 카테고리 id로 객체를 찾아서 가져옵니다. / 유효하지 않는 카테고리일 경우 404 에러가 나옵니다.
        category = AccountCategory.objects.filter(
            user=request.user.id, id=request.data.get('account_category'), delete_flag=False
        ).first()
        serializer = AccountDetailPostSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    # 가계부 내역 수정: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역을 수정합니다.
    @swagger_auto_schema(request_body=AccountDetailPostSerializer, responses={200: AccountDetailPostSerializer})
    def patch(self, request, book_id, accounts_id):
        account_book = get_object_or_404(AccountBook, id=book_id, user=request.user.id)  # 가계부
        account_detail = get_object_or_404(AccountDetail, account_book=account_book.id, id=accounts_id)  # 가계부 내역
        serializer = AccountDetailPostSerializer(account_detail, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_200_OK)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    # 가계부 내역 상세조회: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역을 보여줍니다.
    @api_view(['GET'])
    @swagger_auto_schema(responses={200: AccountDetailPostSerializer})
    def detail(request, book_id, accounts_id):
        account_book = get_object_or_404(AccountBook, id=book_id, user=request.user.id)  # 가계부
        account_detail = get_object_or_404(AccountDetail, account_book=account_book.id, id=accounts_id)  # 가계부 내역
        serializer = AccountDetailSerializer(account_detail)
        return Response(serializer.data, status=status.HTTP_200_OK)

    # 가계부 내역 삭제, 복구: delete_flag 상태만 변경해서 삭제를 구현했습니다.
    @api_view(['PATCH'])
    @swagger_auto_schema(request_body={}, responses={200: {}})
    def toggle_active(request, book_id, accounts_id):
        account_book = get_object_or_404(AccountBook, id=book_id, user=request.user.id)  # 가계부
        account_detail = get_object_or_404(AccountDetail, account_book=account_book.id, id=accounts_id)  # 가계부 내역
        account_detail.toggle_active()
        return Response(account_detail.delete_message, status=status.HTTP_200_OK)

 

 

03. account_books/models.py

 

  • account_type에서 choices를 사용해서 수입, 지출을 구분했다.
  • 미리 작성해둔 TimeStampModel을 상속받아서 각 모델마다 created_at, updated_at 이 자동으로 작성되었다.
  • delete_flag로 삭제 상태만 변경하였다.
# 가계부 상세내용
class AccountDetail(TimeStampModel, DeleteFlag):

    Type = [
        ('0', "지출"),
        ('1', "수입"),
    ]

    account_category = models.ForeignKey(
        'AccountCategory', related_name='account_details', verbose_name='카테고리', null=True, on_delete=models.DO_NOTHING
    )
    account_book = models.ForeignKey('AccountBook', related_name='account_details', verbose_name='가계부', on_delete=models.CASCADE)
    written_date = models.DateTimeField()
    price = models.DecimalField(max_digits=9, decimal_places=0)
    description = models.CharField(blank=True, null=True, max_length=255)
    account_type = models.CharField(choices=Type, default='0', max_length=10)  # 수입, 지출 구분
    delete_flag = models.BooleanField(default=False)

    class Meta:
        db_table = 'account_detail'

    def __str__(self):
        return f'book_name: {self.account_book.book_name} / price: {self.price}'

 

 

04. account_books/tests/test_account_books_detail.py

 

  • 위의 views.py에서 작성했던 5가지 기능들을 전부 성공, 실패로 나누어서 테스트하였다.
  • 주로 테스트는 상태 코드가 일치하는지를 기준으로 진행하였다.
import json

from account_books.models import AccountBook, AccountCategory, AccountDetail
from account_books.views.account_book_detail_views import AccountBookDetailView
from django.urls import reverse
from payhere.test_models import LoginTestModel
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
from users.models import User


class AccountBookDetailTestCase(APITestCase):

    '''
    Writer: 나
    Date: 2022-07-06

    가계부 내역의 CRUD 구현
    GET: 가계부 내역 전체 리스트
    POST: 가계부 내역 생성
    PATCH: 가계부 내역 수정
    detail.GET: 가계부 내역 상세조회
    toggle_active.PATCH: 가계부 내역 삭제, 복구
    '''

    # 초기 데이터 생성
    def setUp(self):

        # 유저 생성
        self.user = User.objects.create_user(nickname='test2', email='test2@gmail.com', password='test2@A!')
        self.login_test = LoginTestModel(self.user)

        # 가계부 생성
        self.account_book = AccountBook.objects.create(
            user=self.user, book_name='test', budget='10000', delete_flag=False
        )

        # 카테고리 생성
        self.account_category = AccountCategory.objects.create(user=self.user, category_name='식비')

        # 가계부 내역 생성
        self.account_book_detail = AccountDetail.objects.create(
            written_date='2022-07-06T15:07:35+09:00',
            price=1500,
            description="subway",
            account_type=1,
            account_category=self.account_category,
            account_book=self.account_book,
        )

    # [성공] 가계부 내역의 전체 리스트를 조회합니다.
    def test_success_get_account_book_list(self):

        url = '/account-books//accounts'
        response = self.login_test.login_user_case(
            self.account_book.id, view=AccountBookDetailView.as_view(), url=url, method='get'
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    # [성공] 가계부 내역에서 1개의 내역을 생성합니다.
    def test_success_post_account_book_object(self):

        data = {
            'written_date': '2022-07-06T15:07:35+09:00',
            'price': 1500,
            'account_type': 1,
            'account_category': self.account_category.id,
            'account_book': self.account_book.id,
        }

        url = '/account-books//accounts'
        response = self.login_test.login_user_case(
            self.account_book.id, view=AccountBookDetailView.as_view(), url=url, method='post', data=data
        )
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

    # [실패] 가계부 내역에서 1개의 내역을 생성합니다. / 빈 data를 보내서 테스트합니다.
    def test_fail_post_account_book_object(self):

        data = {}
        url = '/account-books//accounts'
        response = self.login_test.login_user_case(
            self.account_book.id, view=AccountBookDetailView.as_view(), url=url, method='post', data=data
        )
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    # [성공] 가계부 내역 수정: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역 수정을 테스트합니다.
    def test_success_patch_account_book_object(self):

        data = {
            'written_date': '2022-07-06T15:07:35+09:00',
            'price': 50000,
            'description': "train",
            'account_type': 1,
            'account_category': self.account_category.id,
            'account_book': self.account_book.id,
        }

        url = 'account-books//accounts/detail/'
        response = self.login_test.login_user_case(
            self.account_book.id,
            self.account_book_detail.id,
            view=AccountBookDetailView.as_view(),
            url=url,
            method='patch',
            data=data,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    # [실패] 가계부 내역 수정: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역 수정을 테스트합니다. / 빈 데이터를 보내서 테스트 합니다.
    def test_fail_patch_account_book_object(self):

        data = {}
        url = 'account-books//accounts/detail/'
        response = self.login_test.login_user_case(
            self.account_book.id,
            self.account_book_detail.id,
            view=AccountBookDetailView.as_view(),
            url=url,
            method='patch',
            data=data,
        )
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    # [성공] 가계부 내역 상세조회: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역을 테스트합니다.
    def test_success_get_account_book_object(self):

        url = 'account-books//accounts/detail/'
        response = self.login_test.login_user_case(
            self.account_book.id,
            self.account_book_detail.id,
            view=AccountBookDetailView.detail,
            url=url,
            method='get',
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    # [실패] 가계부 내역 상세조회: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역을 테스트합니다. / 존재하지 않는 book_id, accounts_id로 테스트합니다.
    def test_fail_get_account_book_object(self):

        url = 'account-books//accounts/detail/'
        response = self.login_test.login_user_case(0, 0, view=AccountBookDetailView.detail, url=url, method='get')
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    # [성공] 가계부 내역 삭제: 가계부 id, 가계부 내역 id를 받고 해당되는 단일 내역 삭제를 테스트합니다. (delete_flag 상태만 변경됩니다.)
    def test_success_patch_delete_flag(self):

        url = 'account-books//accounts//deleted'

        # 한번에 삭제, 복구 둘 다 테스트하게 됩니다.
        # (1) 삭제 혹은 복구를 테스트합니다.
        response = self.login_test.login_user_case(
            self.account_book.id,
            self.account_book_detail.id,
            view=AccountBookDetailView.toggle_active,
            url=url,
            method='patch',
        )

        self.account_book_detail.refresh_from_db()

        self.assertEqual(self.account_book_detail.delete_flag, True)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        # (1) 위에서 했던 테스트의 반대 행동을 테스트 합니다.
        response = self.login_test.login_user_case(
            self.account_book.id,
            self.account_book_detail.id,
            view=AccountBookDetailView.toggle_active,
            url=url,
            method='patch',
        )

        self.account_book_detail.refresh_from_db()

        self.assertEqual(self.account_book_detail.delete_flag, False)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

 


 

후기

 

  • 테스트 코드 작성이 아직 익숙하지 않아서 초반에 많이 어려웠는데 팀원 분들 덕분에 기한 내 작성할 수 있었다.
  • 이번 프로젝트는 Docker를 이용해서 웹 서버와 DB를 띄웠다. Docker-compose 사용법을 익힐 수 있었다.
  • 최종 프로젝트도 배포되었다. 배포는 내가 담당하지 않아서 아직 잘 모르지만 다음 프로젝트 때 더 배우고 싶다.
  • 늦게까지 작업이 진행되어서 자고 바로 다음날 아침부터 이어서 하는 경우가 종종 있었는데
  • 그렇게 열어둔 브라우저 창만 40개가 넘고 기록이 많이 밀렸다. 작업 틈틈이라도 기록하는 습관이 필요하다.
  • 프로젝트를 하면서 좋은 점 중 하나는 내가 모르는 부분을 알 수 있는 점이다.
  • 이번 세션을 통해 JWT 토큰에 대해서 이해할 수 있게 되었다.
Comments