하루만에 완성하는 Django+DRF 서비스(3)

June
None
Published in
12 min readMar 5, 2023

--

출처: https://www.django-rest-framework.org/

안녕하세요. 휴먼스케이프 june입니다.

이번 시간엔category, item, review의 모델 연결관계를 api에서 표현하는 방법, permission을 적용하는 방법(일반유저가 items를 삭제/수정하지 못하도록 관리)에 대해 알아보도록 하겠습니다.

프로젝트를 진행한 코드는 깃허브에 올라가 있으니 참고하시기 바랍니다.

이전 시간에 만든 category api, testcode와 같은 형식으로 item, review에 대한 api, testcode도 생성합니다.

자세한 코드는 github repo에서 확인해주세요.

from test_plus.test import APITestCase

from ..models import Item, Category

class ItemApiTestCase(APITestCase):
def setUp(self) -> None:
self.category = Category.objects.create(name='test category')
self.item = Item.objects.create(name='test item', category=self.category, description='test description')
self.item2 = Item.objects.create(name='test item 2', category=self.category, description='test description 2')

def test_get_items(self):
response = self.get('/reviews/items/')
self.assert_http_200_ok(response)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['name'], self.item.name)
self.assertEqual(response.data[1]['name'], self.item2.name)

def test_get_item(self):
response = self.get(f'/reviews/items/{self.item.id}/')
self.assert_http_200_ok(response)
self.assertEqual(response.data['name'], self.item.name)

def test_create_item(self):
data = {'name': 'test item 3', 'category': self.category.id, 'description': 'test description 3'}
response = self.post('/reviews/items/', data=data)
self.assert_http_201_created(response)
self.assertEqual(response.data['name'], data['name'])
self.assertEqual(Item.objects.count(), 3)
self.assertEqual(Item.objects.last().name, data['name'])

def test_update_item(self):
data = {'name': 'test item 3', 'category': self.category.id, 'description': 'test description 3'}
response = self.put(f'/reviews/items/{self.item.id}/', data=data)
self.assert_http_200_ok(response)
self.assertEqual(response.data['name'], data['name'])
self.assertTrue(Item.objects.filter(name=data['name']).exists())
self.assertFalse(Item.objects.filter(name=self.item.name).exists())

def test_delete_item(self):
response = self.delete(f'/reviews/items/{self.item.id}/')
self.assert_http_204_no_content(response)
self.assertFalse(Item.objects.filter(name=self.item.name).exists())
self.assertEqual(Item.objects.count(), 1)

만든 api들을 확인해보면 잘 동작하는걸 알 수 있습니다.

Model의 연결관계 표현

우리는 category retrieve api를 호출할때 items를, item retrieve api를 호출할 때 reviews를 반환하도록 하고 싶습니다.

(클라이언트 단에서 api 호출을 적게 할수록 로딩 시간이 줄어듭니다.)

CategoryDetailSerializer를 만들어 items를, ItemDetailSerializer를 만들어 reviews field를 추가하고, view에서 action이 retrieve일 경우 DetailSerializer를 사용하도록 설정합니다.

Category retrieve api로 예를 들어 보겠습니다.

DetailSerializer, ~Serializer등 여러 개의 클래스를 생성하면 serializers.py 파일이 너무 커지기 때문에 category_serializers.py로 분리했습니다.

이전에 생성한 ItemSerializer클래스를 불러와 사용하면 ModelSerializer내부에서 items라는 attribute를 찾아 items field에 넣어줍니다.

class CategoryDetailSerializer(serializers.ModelSerializer):
items = ItemSerializer(many=True)
class Meta:
model = Category
fields = '__all__'

CategoryViewSet에서는 retrieve인 경우 DetailSerializer를 사용하도록 수정합니다.

ModelViewSet의 코드를 까보면 내부적으로 get_serializer_class라는 함수를 호출해 serializer를 가져옵니다.

따라서 해당 함수를 overwrite해주면 됩니다.


class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer

def get_serializer_class(self):
if self.action == 'retrieve':
return CategoryDetailSerializer
return CategorySerializer

실제로확인해보면 아래와 같이 잘 나오는것을 볼 수 있습니다.

Permission 적용

category, item항목은 유저가 직접 편집하기에 위험부담이 있습니다.

따라서 해당 항목들은 admin유저만이 편집할 수 있도록 권한을 제어하려 합니다.

DRF에서 Permission을 손쉽게 제어하는 방식을 소개하고 있으니, 이를 활용하면 됩니다.

우리는 DjangoModelPermissionsOrAnonReadOnly 라는 permission_class 를 사용합니다.

views에서 IsAdminUserDjangoModelPermissionsOrAnonReadOnly를 활용해 permission_classes를 작성합니다.

class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]

이제 testcode를 다시 실행해보면 테스트가 실패하는것을 볼 수 있습니다.

category, item의 수정 권한이 admin으로 변경되었기 때문에 해당 api를 이용하기 위해선 admin user로 로그인 해야합니다.

category api testcode를 예로 들겠습니다.

아래와 같이 setUp함수에서 권한을 가진 user를 생성하고, 테스트시에 해당 user로 로그인하도록 코드를 수정합니다.

def setUp(self) -> None:
self.user = self.make_user(perms=['reviews.add_category', 'reviews.change_category', 'reviews.delete_category'])
self.category = Category.objects.create(name='test category')
self.category2 = Category.objects.create(name='test category 2')

...

def test_create_category(self):
with self.login(username=self.user.username):
data = {'name': 'test category 3'}
response = self.post('/reviews/categories/', data=data)
self.assert_http_201_created(response)
self.assertEqual(response.data['name'], data['name'])
self.assertEqual(Category.objects.count(), 3)
self.assertEqual(Category.objects.last().name, data['name'])

다음으로 각 item에 대한 review는 로그인을 한 사람만이 작성할 수 있고, 해당 리뷰를 작성한 사람만이 수정할 수 있도록 제한합니다.

이번 단계에선 Object level permissions 을 활용합니다.

permissions.py 파일을 작성합니다.

class ReviewAuthorOrReadOnly(permissions.BasePermission):
"""
인증 시 사용자는 자신의 리뷰만 수정할 수 있습니다.
인증되지 않은 사용자는 읽기 전용입니다.
"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user.is_authenticated

def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.user == request.user

ReviewViewSet에 추가합니다.

class ReviewViewSet(viewsets.ModelViewSet):
queryset = Review.objects.all()
serializer_class = ReviewSerializer
permission_classes = [ReviewAuthorOrReadOnly]

다시 testcode를 실행해보면 review 부분에서 권한이슈로 테스트가 실패하는걸 확인할 수 있습니다.

review testcode에서도 로그인 코드를 추가해줍니다.

지금까지 연결된 model을 한번에 출력하고, permission을 적용해보았습니다.

다음시간엔 쿼리성능 개선 방법에 대해 알아보도록 하겠습니다.

--

--