Django Review PJ

Haks.·2025년 2월 10일
0

Study

목록 보기
55/65

1일차 📌 목표

  1. django-restframework 에 대해서 이해하고 적절히 활용할 수 있다.
  2. TDD(Test Driven Development 방식에 대해서 이해하고 수행할 수 있다.
  3. Django ORM 을 사용하여 데이터베이스 모델을 구성할 수 있다.
  4. Django Test 에 대해서 이해하고 테스트 코드를 적절히 작성할 수 있다.
  5. Serializer 에 대해서 이해하고 활용할 수 있다.
  6. Viewset 에 대해서 이해하고 적절히 활용할 수 있다.

📌 요구사항

지난 과제에서는 original django를 이용하여 To Do List를 만들어 보았습니다.
이번 과제부터 마지막 챕터까지 TDD 방식과 DRF를 사용하여 맛집 리뷰 웹사이트를 구현하고 배포해보도록 하겠습니다.
아래의 요구사항을 참고하여 프로젝트를 구성하고 Viewset을 이용한 기본적인 CRUD를 구성하세요.

0️⃣ 프로젝트 세팅

  1. Poetry 가상환경을 이용합니다.
  2. django, django-restframework를 설치합니다.
  3. mysql 데이터베이스 연동을 위해 pymysql을 설치합니다.
  4. 이미지 필드 사용을 위해 pillow, django-cleanup 라이브러리를 추가로 설치합니다.
  5. django-admin startproject config . 명령어를 사용하여 장고 프로젝트를 세팅합니다.
  6. python manage.py startapp 명령어를 사용하여 users, restaurants, reviews 앱을 각각 구성합니다.
  7. settings.py에 REST_FRAMEWORK 설정을 추가합니다.
  8. settings.py에 DATABASES 설정을 수정하여 mysql을 연동합니다.
  9. 루트 디렉터리에 secret.json 환경변수 파일을 생성하고 SECRET_KEY, MySQL 연결 정보 등을 입력하여 사용합니다.
  10. templates, media, static 경로를 지정해줍니다.

1️⃣ 데이터베이스 모델

아래의 요구사항을 참고하여 데이터베이스 모델을 구성하세요.

  • User
    • AbstractBaseUser, PermissionsMixin을 상속받아 사용
      • is_superuser, password, last_login 등의 필드가 내재되어 있음
    • add fields
      • nickname - CharField(max_length=20, unique=True)
      • email - EmailField(max_length=40, unique=True)
      • profile_image - ImageField(upload_to=’users/profile_images’, default=’users/blank_profile_image.png’)
        • blank_profile_image.png 는 구글 검색을 통해서 다운로드 받아서 사용.
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import PermissionsMixin
from django.db import models


class UserManager(BaseUserManager):
    def create_user(self, email, password, nickname,*args, **kwargs):
        if not email or not nickname:
            raise ValueError("이메일, 닉네임을 입력하세요")
        user = self.model(
            email=self.normalize_email(email),
            nickname=nickname,*args, **kwargs
        )
        user.set_password(password)
        user.save(using=self._db)
        return user
    def create_superuser(self, email, password, nickname):
        user = self.create_user(email, password, nickname)
        user.is_superuser = True
        user.is_staff = True
        user.save(using=self._db)
        return user

# Create your models here.
class User(AbstractBaseUser, PermissionsMixin): # Permission : is_superuser
    nickname = models.CharField('닉네임', max_length=20, unique=True)
    email = models.CharField('이메일', max_length=40, unique=True)
    profile_image = models.ImageField(upload_to='users/profile_image', default='users/blank_profile_image.png')
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)

    objects = UserManager()
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['nickname']

    def __str__(self):
        return self.email

    class Meta:
        verbose_name = '유저'
        verbose_name_plural =  '유저 목록'
  • BaseModel

    • config / models.py 에 선언

    • fields
      - created_at - DatetimeField(auto_now_add=True)
      - modified_at - DatetimeField(auto_now=True)

      from django.db import models
      
      class BaseModel(models.Model):
          created_at = models.DateTimeField(auto_now_add=True)
          modified_at = models.DateTimeField(auto_now=True)
          class Meta:
              abstract = True
  • Restaurant

    • BaseModel을 상속받아 사용
    • fields
      • name - CharField(max_length=50)
      • address - CharField(max_length=200)
      • contact - CharField(max_length=50)
      • open_time - TimeField(null=True, blank=True)
      • close_time - TimeField(null=True, blank=True)
      • last_order - TimeField(null=True, blank=True)
      • regular_holiday - CharField(choice=DAYS_OF_WEEK, null=True, blank=True)
        DAYS_OF_WEEK = [
                ('MON', 'Monday'),
                ('TUE', 'Tuesday'),
                ('WED', 'Wednesday'),
                ('THU', 'Thursday'),
                ('FRI', 'Friday'),
                ('SAT', 'Saturday'),
                ('SUN', 'Sunday'),
            ]
from django.db import models

from config.models import BaseModel


# Create your models here.

class Restaurant(BaseModel):
    DAYS_OF_WEEK = [
        ('MON', 'Monday'),
        ('TUE', 'Tuesday'),
        ('WED', 'Wednesday'),
        ('THU', 'Thursday'),
        ('FRI', 'Friday'),
        ('SAT', 'Saturday'),
        ('SUN', 'Sunday'),
    ]
    name = models.CharField(max_length=50)
    address = models.CharField(max_length=200)
    contact = models.CharField(max_length=50)
    open_time = models.TimeField(null=True, blank=True)
    close_time = models.TimeField(null=True, blank=True)
    last_order = models.TimeField(null=True, blank=True)
    regular_holiday = models.CharField(choices=DAYS_OF_WEEK, null=True, blank=True, max_length=10)

    def __str__(self):
        return self.name
  • Review
    • BaseModel을 상속받아 사용
    • fields
      • user = ForeignKey(User, on_delete=models.CASCADE)
      • restaurant = ForeignKey(Restaurant, on_delete=models.CASCADE)
      • title = CharField(max_length=50)
      • comment = TextField()
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import ForeignKey

from config.models import BaseModel
from restaurants.models import Restaurant

User = get_user_model()

# Create your models here.
class Review(BaseModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE)
    title = models.CharField(max_length=50)
    comment = models.TextField()

    def __str__(self):
        return f"{self.restaurant.name} 리뷰"

2️⃣ Model Test Code

아래의 요구사항을 참고하여 Model Test Code를 작성하세요.

  • users / tests.py

    from django.contrib.auth import get_user_model
    from django.test import TestCase
    
    # Create your tests here.
    
    User = get_user_model()
    
    class UserModelTest(TestCase):
        def setUp(self):
            self.user = {
                'email' : 'test@test.com',
                'nickname' :'test',
                'password' : 'password1234'
            }
            self.super_user = {
                'email': 'admin@example.com',
                'nickname': 'admin_user',
                'password': 'password1234',
            }
    
        def test_user_manager_create_user(self):
            user = User.objects.create_user(**self.user)
    
            self.assertEqual(User.objects.count(), 1)
            self.assertEqual(user.email, self.user['email'])
            self.assertFalse(user.is_staff, False)
            self.assertFalse(user.is_superuser, False)
            self.assertTrue(user.is_active, True)
    
        def test_user_manager_create_superuser(self):
            user = User.objects.create_superuser(**self.super_user)
    
            self.assertEqual(User.objects.count(), 1)
            self.assertTrue(user.is_staff, True)
            self.assertTrue(user.is_active, True)
            self.assertEqual(user.profile_image.url, '/media/users/blank_profile_image.png')
    
  • restaurants / tests.py

    from django.test import TestCase
    
    from restaurants.models import Restaurant
    
    
    # Create your tests here.
    
    class RestaurantTestCase(TestCase):
        def setUp(self):
            self.restaurant_info = {
                'name' : 'test',
                'address' :'주소',
                'contact' :'없음',
                'regular_holiday' :'MON'
            }
    
        def test_create_restaurant(self):
            restaurant = Restaurant.objects.create(**self.restaurant_info)
    
            self.assertEqual(Restaurant.objects.count(), 1)
            self.assertEqual(restaurant.contact, '없음')
            self.assertEqual(restaurant.address, self.restaurant_info['address'])
    
  • reviews / tests.py

from django.contrib.auth import get_user_model
from django.test import TestCase

from restaurants.models import Restaurant
from reviews.models import Review


# Create your tests here.

class ReviewTestCase(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(
            nickname='test', email='test@example.com', password='password1234'
        )
        self.restaurant = Restaurant.objects.create(
            name='Test Restaurant',
            address='test',
            contact='없음'
        )
        self.data = {
            'user': self.user,
            'restaurant': self.restaurant,
            'title': 'test',
            'comment': 'test'
        }
    def test_create_review(self):
        review = Review.objects.create(**self.data)

        self.assertEqual(review.title, self.data['title'])
        self.assertEqual(review.comment, 'test')
        self.assertEqual(review.user, self.data['user'])
        self.assertEqual(review.restaurant, self.data['restaurant'])

3️⃣ View Test Code

TDD 방식을 따라 코드를 구현하기 위해서 View Test Code를 먼저 작성합니다.

아래의 요구사항을 참고하여 TestCode를 작성해주세요.

# restaurant/test.py

from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status

from restaurants.models import Restaurant


# Create your tests here.

class RestaurantTestCase(TestCase):
    def setUp(self):
        self.restaurant_info = {
            'name' : 'test',
            'address' :'주소',
            'contact' :'없음',
            'regular_holiday' :'MON'
        }

    def test_create_restaurant(self):
        restaurant = Restaurant.objects.create(**self.restaurant_info)

        self.assertEqual(Restaurant.objects.count(), 1)
        self.assertEqual(restaurant.contact, '없음')
        self.assertEqual(restaurant.address, self.restaurant_info['address'])

class RestaurantViewTestCase(APITestCase):
    def setUp(self):
        self.restaurant_info = {
            'name': 'test',
            'address': '주소',
            'contact': '없음',
            'regular_holiday': 'MON'
        }
    def test_restaurant_list_view(self):
        url = reverse('restaurant-list')
        restaurant = Restaurant.objects.create(**self.restaurant_info)

        # client.get -> 해당 엔드포인트로 GET요청을 보낸후 , JSON 형식으로 응답을 받는다.
        response = self.client.get(url)


        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('results')[0].get('name'), 'test')
        self.assertEqual(response.data.get('results')[0]['regular_holiday'], self.restaurant_info['regular_holiday'])

    def test_restaurant_post_view(self):
        # restaurant-create 는 존재하지 않음
        url = reverse('restaurant-list')

        response = self.client.post(url, data=self.restaurant_info, format='json')

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Restaurant.objects.count(), 1)
        self.assertEqual(response.data['name'], 'test')

    def test_restaurant_detail_view(self):
        restaurant = Restaurant.objects.create(**self.restaurant_info)
        url = reverse('restaurant-detail', kwargs={'pk':restaurant.pk})

        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['name'], 'test')
        self.assertEqual(response.data.get('name'), 'test')

    def test_restaurant_update_view(self):
        restaurant = Restaurant.objects.create(**self.restaurant_info)
        url = reverse('restaurant-detail', kwargs={'pk':restaurant.pk})

        response = self.client.patch(url, {'name':'patch'}, format='json')

        self.assertEqual(response.data.get('name'),'patch')
    
    def test_restaurant_delete_view(self):
        restaurant = Restaurant.objects.create(**self.restaurant_info)

        url = reverse('restaurant-detail', kwargs={'pk':restaurant.pk})
        response = self.client.delete(url)
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(Restaurant.objects.count(), 0)

4️⃣ Serializer

rest_framework.serializers 에서 ModelSerializer를 사용하여 Restaurant 모델에 대한 시리얼라이저를 구성합니다.

아래의 요구사항을 충족하는 serializers.py를 생성해주세요.

restaurants / serializers.py

  • RestaurantSerializer
    • ModelSerializer 를 상속받아 사용
    • Restaurant 모델에 대한 serializer
    • fields
      • 모든 필드를 읽기 및 쓰기가 가능하도록 설정
from rest_framework import serializers

from restaurants.models import Restaurant


class RestaurantSerializer(serializers.ModelSerializer):
    class Meta:
        model = Restaurant
        fields = '__all__'

5️⃣ View

  • restaurant / views.py
    • RestaurantViewset
      • rest_framework.viewsetsModelViewSet 을 활용하세요.
      • permission_class 는 따로 지정하지 않습니다. (추후 추가 예정)
from django.shortcuts import render

# Create your views here.
from rest_framework import viewsets, permissions

from restaurants.models import Restaurant
from restaurants.serializers import RestaurantSerializer


class RestaurantViewSet(viewsets.ModelViewSet):
    queryset = Restaurant.objects.all()
    serializer_class = RestaurantSerializer

6️⃣ URL

이번 프로젝트에서는 URL Include 방식을 사용하여 url 엔드포인트들을 구성하겠습니다.

아래의 요구사항을 충족하는 앱의 urls.py 생성하고 config / urls.py 에서 include 를 사용해주세요.

  • restaurants / urls.py
    • Restaurant 에 관한 View는 ViewSet을 활용합니다.
      • rest_framwork.routersDefaultRouter를 활용하면 ViewSet을 사용할 때 각각의 요청 메서드에 따라 아래의 url 엔드포인트를 생성해줍니다.
        • restaurants/
          • Restaurant 모델 생성 요청을 처리할 POST, 리스트 요청을 처리할 GET 메서드를 담당할 url 엔드포인트
        • restaurants/<int:pk>/
          • Restaurant 모델 수정 요청을 처리할 PUT, PATCH, 상세정보 요청을 처리할 GET, 삭제를 처리하는 DELETE 메서드를 담당할 url 엔드포인트
# restaurants/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from restaurants import views

router = DefaultRouter()
router.register(r'restaurants', views.RestaurantViewSet, basename='restaurant')

urlpatterns = [
    path('', include(router.urls)),
]
  • config / urls.py
    • restaurants / urls.py에 선언된 router.urls 를 include해주세요.
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('restaurants.urls')),
]

2일차 📌 목표

  1. django-restframework 에 대해서 이해하고 적절히 활용할 수 있다.
  2. TDD(Test Driven Development 방식에 대해서 이해하고 수행할 수 있다.
  3. Django ORM 을 사용하여 데이터베이스 모델을 구성할 수 있다.
  4. Django Test 에 대해서 이해하고 테스트 코드를 적절히 작성할 수 있다.
  5. Serializer 에 대해서 이해하고 활용할 수 있다.
  6. rest_framework.viewsAPI View에 대해서 이해하고 적절히 활용할 수 있다.
  7. rest_framework.generics 의 각각의 View Class에 대해서 이해하고 적절히 활용할 수 있다.

📌 요구사항

지난 과제에서는 TDD 방식과 ViewSet를 이용하여 Restaurant 모델에 대한 url 을 구성해보았습니다.
이번 과제에서는 TDD 방식과 APIView를 이용하여 유저 회원가입 및 로그인, 로그아웃 기능을, GenericView를 이용하여 Review 기능을 간단하게 구현해보겠습니다.
아래의 요구사항을 참고하여 코드를 구성하세요.

0️⃣ settings.py

REST_FRAMEWORK 설정에 아래의 요구사항을 추가해주세요.

  • DEFAULT_AUTHENTICATION_CLASS - SessionAuthentication
    → 세션 인증 방식으로 로그인을 진행하기 위함
  • DEFAULT_PERMISSION_CLASS - IsAuthenticatedOrReadOnly
    → 인증된 사용자는 모든 요청이 가능하고 인증되지 않은 사용자는 Get 요청만 가능하도록 설정
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ]
}

1️⃣ View Test Code

TDD 방식을 따라 코드를 구현하기 위해서 View Test Code를 먼저 작성합니다.

아래의 요구사항을 참고하여 TestCode를 작성해주세요.

  • 3가지 방법이 있다. 각각의 필요한곳에 self.client.force_authenticate(user=self.user) or setup 에서 login을 시켜주는방법
# restaurants/tests.py
class RestaurantViewTestCase(APITestCase):
	def setUp(self):
		self.restaurant_info = {
            'name': 'test',
            'address': '주소',
            'contact': '없음',
            'regular_holiday': 'MON'
        }
        self.user = get_user_model().objects.create_user(
            email='test@test.com',
            nickname='test',
            password='password1234',
        )
        # self.client.force_authenticate(user=self.user)
        self.client.login(email='test@test.com', password='password1234')
    # 필요한 곳에 적용시키거나
    def test_restaurant_update_view(self):
        restaurant = Restaurant.objects.create(**self.restaurant_info)
        url = reverse('restaurant-detail', kwargs={'pk':restaurant.pk})

        self.client.force_authenticate(user=self.user)
        response = self.client.patch(url, {'name':'patch'}, format='json')
# users/tests.py

class UserAPIViewTestCase(APITestCase):
    def setUp(self):
        self.user = {
            'email': 'test@test.com',
            'nickname': 'test',
            'password': 'password1234'
        }

    def test_user_signup(self):
        url = reverse('user-signup')
        response = self.client.post(url, self.user)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(User.objects.count(), 1)
        self.assertEqual(response.data.get('nickname'), 'test')

    def test_user_login(self):
        user = User.objects.create_user(**self.user)
        url = reverse('user-login')
        data = {
            'email' : self.user.get('email'),
            'password': self.user.get('password'),
        }
        response = self.client.post(url, data)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('message'), 'login success')

    def test_user_login_invalid_credentials(self):
        user = User.objects.create_user(**self.user)
        data = {
            'email': 'test@test.com',
            'password': 'wrong'
        }
        response = self.client.post(reverse('user-login'), data)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    def test_get_user_detail(self):
        user = User.objects.create_user(**self.user)
        url = reverse('user-detail', kwargs={'pk': user.pk})
        # 로그인 시키는 위치 중요
        self.client.login(email=self.user['email'], password=self.user['password'])

        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('email'), 'test@test.com')

    def test_update_user_details(self):
        user = User.objects.create_user(**self.user)
        url = reverse('user-detail', kwargs={'pk': user.pk})
        # 로그인 시키는 위치 중요
        self.client.login(email=self.user['email'], password=self.user['password'])

        data = {
            'nickname' : 'update_test'
        }
        response = self.client.patch(url, data)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('nickname'), 'update_test')

    def test_delete_user(self):
        user = User.objects.create_user(**self.user)
        url = reverse('user-detail', kwargs={'pk': user.pk})
        # 로그인 시키는 위치 중요
        self.client.login(email=self.user['email'], password=self.user['password'])

        response = self.client.delete(url)

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(User.objects.count(), 0)
# reviews/tests.py

class ReviewAPIViewTestCase(APITestCase):
    def setUp(self):
    # 테스트 코드를 작성하기 위해 필요한 것들을 미리 생성해두는 것
        self.user = get_user_model().objects.create_user(
            nickname='test', email='test@example.com', password='password1234'
        )
        self.client.login(email='test@example.com', password='password1234')
        self.restaurant = Restaurant.objects.create(
            name='Test Restaurant',
            address='test',
            contact='없음'
        )
        self.data = {
            'user': self.user,
            'restaurant': self.restaurant,
            'title': 'test',
            'comment': 'test'
        }

    def test_get_review_list(self):
    # 작성된 리뷰 리스트를 가져오기 위한 APIView 테스트를 위한 코드 작성
        review = Review.objects.create(**self.data)
        url = reverse('review-list-create', kwargs= {'restaurant_pk': review.restaurant.pk})
        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(Review.objects.count(), 1)


    def test_post_review(self):
    # 리뷰를 작성하면 데이터베이스에 저장되는 APIView 테스트를 위한 코드 작성
        url = reverse('review-list-create', kwargs={'restaurant_pk': self.restaurant.pk})
        data ={
            'title':'test',
            'comment':'test'
        }
        response = self.client.post(url, data, format='json')

        print(response.data)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Review.objects.count(), 1)

    def test_get_review_detail(self):
    # 리뷰 상세 정보를 가져오기 위한 APIView 테스트를 위한 코드 작성
        review = Review.objects.create(**self.data)
        url = reverse('review-detail', kwargs={'review_pk': review.pk})
        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('title'), 'test')

    def test_update_review(self):
    # 리뷰 제목, 내용을 업데이트하기 위한 APIView 테스트를 위한 코드 작성
        review = Review.objects.create(**self.data)
        url = reverse('review-detail', kwargs={'review_pk': review.pk})
        data = {
            'title' :'update'
        }
        response = self.client.patch(url, data)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data.get('title'), 'update')

    def test_delete_review(self):
        # 리뷰를 삭제하는 APIView 테스트를 위한 코드 작성
        review = Review.objects.create(**self.data)
        url = reverse('review-detail', kwargs={'review_pk': review.pk})
        response = self.client.delete(url)

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(Review.objects.count(), 0)
        self.assertFalse(Review.objects.filter(pk=review.pk).exists())

2️⃣ Serializer

rest_framework.serializers 에서 ModelSerializer를 사용하여 각 모델에 대한 시리얼라이저를 구성합니다.

아래의 요구사항에 따라 serializers.py 를 수정하거나 추가합니다.

users / serializers.py

  • UserSerializer
    • fields
      • id, nickname, email, password, is_staff, is_superuser
    • def create(self, validated_data)
      • 시리얼 라이저를 사용하는 APIView 에서 ModelSerializer를 시리얼라이저로 사용할 때, POST 메서드일 떄 이 함수가 호출됩니다.
        검증된 데이터를 바탕으로 회원가입을 수행하는 코드를 작성해 주세요.
        비밀번호는 set_password() 를 사용하여 생성해주세요.
class UserSerializer(serializers.ModelSerializer):
    # 보안상의 이유로 클라이언트가 입력은 가능하지만 API응답에는 포함안시키기위해 
    password = serializers.CharField(write_only=True)
    class Meta:
        model = get_user_model()
        fields = ('id', 'nickname', 'email', 'password','is_staff','is_superuser',)
    
    # 비밀전호를 평문으로 저장하지 않고 해싱해서 저장하기 위해사용
    def create(self, request):
        # pop메소드는 제거한 값을 반환함
        password = request.pop('password', None)
        if not password:
            raise serializers.ValidationError("패스워드를 입력하세요")
        user = get_user_model().objects.create_user(**request)
        user.set_password(password)
        user.save()
        return user
  • UserDetailSerializer
    • ModelSerializer 를 상속받아 사용
    • fields
      • id, nickname, email, password, profile_image
      • id, nickname 은 read_only_fields 로 설정
      • password는 write_only 필드로 설정 (인증정보는 클라이언트에 전달하지 않습니다.)
    • def update(self, instance, validated_data)
      • 시리얼 라이저를 사용하는 APIView 에서 ModelSerializer를 시리얼라이저로 사용할 때, put 또는 patch 메서드로 요청이오면 update 메서드가 호출됩니다.
        비밀번호 변경 시 set_password() 를 사용하여 업데이트 하도록 해당 메서드를 구성해주세요.
class UserDetailSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True)
    class Meta:
        model = get_user_model()
        fields = ['id,','nickname','email','password','profile_image']
        READ_ONLY_FIELDS = ['id','email']

        # 수정할 객체, 클라이언트가 보내는 형식
    def update(self, instance, validated_data):
        user = get_user_model().objects.get(pk=instance.pk)
        password = validated_data.pop('password', None)
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        
        if password:
            user.set_password(password)
        instance.save()
        return instance
  • UserLoginSerializer
    • Serializer 를 상속받아 사용
    • fields
      • email - required,
      • password - required, write_only
    • def validate(self, attrs)
      • 시리얼 라이저를 사용하는 APIView 에서 Serializer를 상속받아 시리얼라이저로 사용할 때 이 함수가 호출됩니다. 요청으로부터 받은 데이터를 검증하는 메서드입니다.
        이메일과 비밀번호를 검증하고 검증된 유저를 반환하는 코드를 작성해주세요.
# class UserLoginSerializer(serializers.Serializer):
#     email = serializers.EmailField()
#     password = serializers.CharField(write_only=True)
#     def validate(self, attrs):
#         email = attrs.get('email')
#         password = attrs.get('password')
# 
#         if not email or not password:
#             raise serializers.ValidationError("Email과 Password는 필수 입력 항목입니다")
# 
#         user = authenticate(email=email, password=password)
# 
#         if not user:
#             raise serializers.ValidationError("올바른 email과 password를 입력하세요.")
# 
#         attrs['user'] = user
# 
#         return attrs


# authenticate() : 사용자의 자격 증명을 확인하고, 유효한 경우 사용자 객체를 반환하는 함수, 
# 인증이 성공하면 해당 사용자 객체(User)를 반환
# 인증이 실패하면 None을 반환
# 📌 self.context란?
# Django REST Framework(DRF)에서 Serializer는 뷰(View)에서 전달된 context를 가질 수 있음.
# self.context.get('request')로 요청 객체를 가져올 수 있음.
# serializer = UserLoginSerializer(data=request.data, context={"request": request})
# ✔ 즉, 기본적으로 attrs에는 email과 password가 있지만,
# ✔ request는 현재 요청을 보낸 사용자에 대한 추가적인 정보를 포함하는 역할을 함.
# Django 기본 인증 방식 중 하나인 **SessionAuthentication**을 사용할 경우, 로그인한 사용자의 세션을 확인할 수 있음.
# request를 전달하면, Django가 현재 세션에서 로그인된 사용자 정보를 확인하는 데 활용할 수 있음.
# ✔ authenticate(request, username, password)를 사용하면, Django가 세션을 유지하며 로그인할 수 있도록 지원

class UserLoginSerializer(serializers.Serializer):
    email = serializers.CharField(required=True)
    password = serializers.CharField(required=True, write_only=True)
    
    def validate(self, attrs):
        email = attrs.get('email')
        password = attrs.get('password')

        if email and password:
            user = authenticate(request=self.context.get('request'), **attrs)
            if not user:
                raise serializers.ValidationError(
                    # 오류 메세지 표준화  
                    # detail 오류 메시지 전달
                    # code 오류의 종류 지정
                    detail='Unable to log in with provided credentials.', code='authorization'
                )
        else:
            raise serializers.ValidationError(
                detail='Must be Required "email" and "password".', code='authorization'
            )

        attrs['user'] = user
        return attrs

reviews / serializers.py

  • ReviewSerializer
    • ModelSerializer 를 상속받아 사용
    • Review Create, List APIView에 대한 serializer
    • fields
      • user - UserDetailSerializer를 가져와서 유저정보를 반환, read_only=True
      • id - read_only_fields
      • restaurant - read_only_fields
      • title
      • comment
  • ReviewDetailSerializer
    • ModelSerializer 를 상속받아 사용
    • Review Update, Retrieve APIView에 사용할 serializer
    • fields
      • user - UserDetailSerializer를 가져와서 유저정보를 반환, read_only=True
      • restaurant - RestaurantSerializer를 가져와서 식당정보를 반환, read_only=True
      • id - read_only_fields
      • title
      • comment
class ReviewDetailSerializer(serializers.ModelSerializer):
    user = UserDetailSerializer(read_only=True)
    restaurant = RestaurantSerializer(read_only=True)
    # ✔ user 필드는 UserDetailSerializer를 사용하여 직렬화
    # ✔ 즉, Review 모델의 user 필드가 단순한 id가 아니라 사용자 정보 전체를 포함하도록 변환됨.
    # ✔ read_only=True → POST 요청 시 클라이언트가 값을 보내지 않아도 됨 (perform_create()에서 자동 설정됨).
    # 직렬화 시키지 않으면 Django REST Framework는 User와 Restaurant 객체를 자동으로 JSON으로 변환할 수 없음
    # 따라서, 단순히 변수 이름을 바꿔도 해결되지 않음

    class Meta:
        model = Review
        fields = "__all__"

        READ_ONLY_FIELDS = ['pk','restaurant']

class ReviewSerializer(serializers.ModelSerializer):
    # 리뷰 제목, 텍스트와 함께 작성자의 정보, 레스토랑의 정보를 함께 보냄
    user = UserDetailSerializer(read_only=True) 

    class Meta:
        model = Review
        fields = "__all__"
        # user, restaurant 필드는 serializer.save()의 인자로 전달할 것이기 때문에 읽기 전용 필드로 설정
        read_only_fields = ["id", "restaurant"]

3️⃣ View

  • users / views.py
    • UserSignupView
      • rest_framework.genericsCreateAPIView 를 상속받아 구현
      • permission_class 는 모든 유저가 접근이 가능하도록 설정
      • serializer_class 는 UserSerializer 사용
    • UserLoginView
      • rest_framework.viewsAPIView 를 상속받아 구현
      • permission_class 는 모든 유저가 접근이 가능하도록 설정
      • POST 요청을 받아 요청 데이터를 시리얼라이저를 사용하여 검증하고 검증된 유저로 로그인을 진행하도록 구현
    • UserDetailView
      • rest_framework.genericsRetrieveUpdateDestroyAPIView 를 상속받아 구현
      • queryset 은 모든 유저에 대한 쿼리셋을 사용
      • serializer_class 는 UserDetailSerializer 사용
      • get_object 메서드를 오버라이드 하여 요청을 보낸 유저(즉, 로그인된 사용자)가 자신의 정보만 조회, 수정, 삭제할 수 있도록 설정
from django.contrib.auth import login, get_user_model
from django.shortcuts import render
from rest_framework import status
from rest_framework.generics import CreateAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from users.serializers import UserSerializer, UserLoginSerializer, UserDetailSerializer


class UserCreatAPIView(CreateAPIView):
    serializer_class = UserSerializer
    permission_classes = [AllowAny]

class UserLoginAPIView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = UserLoginSerializer(data=request.data)
        if serializer.is_valid():
            login(request, serializer.validated_data.get('user'))
            return Response({'message': 'login success'}, status=status.HTTP_200_OK)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

class UserDetailView(RetrieveUpdateDestroyAPIView):
    queryset = get_user_model().objects.all()
    serializer_class = UserDetailSerializer

    # 로그인 처리
    def get_object(self):
        return get_object_or_404(get_user_model(), pk = self.request.user.pk)
  • reviews / views.py
    • ReviewListCreateView
      • rest_framework.genericsListCreateView 를 상속받아 구현
      • serializer_class - ReviewSerializer 사용
      • queryset - Review를 최신순으로 정렬한 쿼리셋 사용
      • def get_queryset(self)
        • url 파라미터로 전달받은 restaurant_id를 사용하여 쿼리셋을 필터링한 값을 반환
      • def perform_create(self)
        • post 메서드 요청시 호출되는 메서드로 serializer.save() 기능을 함
        • url 파라미터로 전달받은 restaurant_id에 해당하는 식당이 존재하는 지 확인
          • 존재하면 request로부터 user를 가져와서 restaurant, user 객체를 serializer.save()의 매개변수로 전달
          • 존재하지 않으면 404 에러를 반환
    • ReviewDetailView
      • rest_framework.genericsRetriveUpdateDestroyView 를 상속받아 구현
      • serializer_class - ReviewDetailSerializer 사용
      • permission_class - IsAuthenticated
        → 인증된 사용자만 요청을 보낼 수 있도록 설정
      • def get_object(self)
        • url 파라미터로 전달받은 review_id와 request의 user에 해당하는 리뷰를 가져오도록 구현 → 리뷰 수정, 삭제, 상세조회는 본인만 가능하도록 하기 위함.
from rest_framework import serializers

from restaurants.serializers import RestaurantSerializer
from reviews.models import Review
from users.serializers import UserSerializer, UserDetailSerializer


from django.shortcuts import render
from rest_framework.generics import ListCreateAPIView, get_object_or_404, RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated

from restaurants.models import Restaurant
from reviews.models import Review
from reviews.serializers import ReviewSerializer


# Create your views here.


class ReviewListCreateView(ListCreateAPIView):
    queryset = Review.objects.all().order_by('-created_at')
    serializer_class = ReviewSerializer

    def get_queryset(self):
        # Django ORM에서는 외래 키(ForeignKey)를 필터링할 때 _id를 사용해야 함 (restaurant_id)
        return self.queryset.filter(restaurant_id=self.kwargs.get('restaurant_pk'))

    def perform_create(self, serializer):
        restaurant_id = self.kwargs.get('restaurant_pk')
        restaurant = get_object_or_404(Restaurant, pk = restaurant_id)
        serializer.save(user=self.request.user, restaurant=restaurant)

class ReviewDetailView(RetrieveUpdateDestroyAPIView):
    serializer_class = ReviewDetailSerializer
    permission_classes = [IsAuthenticated]

    def get_object(self):
        return get_object_or_404(Review, pk=self.kwargs.get('review_pk'), user=self.request.user)  # ✅ 개별 객체 가져오기


4️⃣ URL

이번 프로젝트에서는 URL Include 방식을 사용하여 url 엔드포인트들을 구성하겠습니다.

아래의 요구사항을 충족하는 앱의 urls.py 생성하고 config / urls.py 에서 include 를 사용해주세요.

  • users / urls.py
    • login/
      • UserLoginView를 매핑해줍니다.
      • name=’user-login’
    • signup/
      • UserSignupView를 매핑해줍니다.
      • name=’user-signup’
    • logout/
      • django.contrib.auth.viewsLogoutView를 매핑해줍니다.
      • name=’user-logout’
    • profile/<int:pk>/
      • UserDetailView를 매핑해줍니다.
      • name=’user-detail’
from django.urls import path
from users import views

urlpatterns =[
    path('signup/', views.UserCreatAPIView.as_view(), name='user-signup'),
    path('login/', views.UserLoginAPIView.as_view(), name='user-login'),
    path('detail/<int:pk>', views.UserDetailView.as_view(), name='user-detail'),

]
  • reviews / urls.py
    • restaurants/<int:restaurant_id>/reviews
      • ReviewListCreateView를 매핑해줍니다.
      • name=’review-list’
    • reviews/<int:review_id>
      • ReviewDetailView를 매핑해줍니다.
      • name=’review-detail’
from django.urls import path
from reviews import views

urlpatterns = [
    path('restaurants/<int:restaurant_pk>/review/', views.ReviewListCreateView.as_view(),
         name='review-list-create'),
    path('review/<int:review_pk>/', views.ReviewDetailView.as_view(),
         name='review-detail'),
]
  • config / urls.py
    • users / urls.py, reviews / urls.py에 선언된 urlpatterns를 include 해주세요.
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('restaurants.urls')),
    path('', include('users.urls')),
    path('',include('reviews.urls')),
]

3일차 📌 목표

  1. django-restframework 에 대해서 이해하고 적절히 활용할 수 있다.
  2. TDD(Test Driven Development) 방식에 대해서 이해하고 수행할 수 있다.
  3. Django Test 에 대해서 이해하고 테스트 코드를 적절히 작성할 수 있다.
  4. rest_framework.viewsAPI View에 대해서 이해하고 적절히 활용할 수 있다.
  5. rest_framework_simplejwt 에 대해서 이해하고 활용할 수 있다.
  6. drf-yasg 에 대해서 이해하고 활용하여 Swagger 문서를 만들 수 있다.
  7. 로컬환경과 배포환경을 각각 적절하게 설정할 수 있다.
  8. PythonAnywhere 를 활용하여 배포하는 과정을 이해하고 배포할 수 있다.

📌 요구사항

지난 과제에서는 TDD 방식과 APIView, GenericView를 이용하여 User, Review 모델에 대한 url 을 구성해보았습니다.
이번 과제에서는 로컬, 배포환경의 설정을 구분해주고 TDD 방식과 djangorestframework_simplejwt를 이용하여 JWT 로그인, 로그아웃 기능을 간단하게 구현해보고 PythonAnywhere을 활용해서 배포까지 진행해보겠습니다.
아래의 요구사항을 참고하여 코드를 구성하세요.

0️⃣ Setting

  • 로컬, 배포 설정 분리하기
    • 기존의 settings.py 파일의 이름을 base.py로 변경하고 config 하위에 settings 폴더를 만들어 이동시켜줍니다.
    • config / settings 하위에 local.py, prod.py 를 생성하고 base.py의 모든 내용을 import 합니다.
    • base.py 파일에서 아래의 내용들을 local.py와 prod.py에 복사 붙여넣기 하고, base.py에서 지워주세요. local.py에서는 기존 설정을 유지해주시고 prod.py에서는 배포환경에 맞게 설정해주세요.
      • DEBUG

      • ALLOW_HOSTS

      • STATIC_URL

      • STATIC_DIRS

      • STATIC_ROOT

      • MEDIA_URL

      • MEDIA_ROOT

      • DATABASES

        # prod.py 예시
        
        from .base import *
        
        DEBUG = False
        
        ALLOWED_HOSTS = ['PythonAnywhere 배포도메인']
        
        # Static
        STATIC_URL = 'static/'
        STATIC_DIRS = [BASE_DIR / 'static']
        STATIC_ROOT = BASE_DIR / '.static_root'
        
        # Media
        MEDIA_URL = 'media/'
        MEDIA_ROOT = BASE_DIR / 'media'
        
        # DB
        DATABASES = {
            'default': {
                'ENGINE': 'django.db.backends.sqlite3',
                'NAME': BASE_DIR / 'db.sqlite3',
            }
        }
        # local.py 예시
        
        from .base import *
        
        DEBUG = True
        
        ALLOWED_HOSTS = []
        
        # Static
        STATIC_URL = 'static/'
        STATIC_DIRS = [BASE_DIR / 'static']
        STATIC_ROOT = BASE_DIR / '.static_root'
        
        # Media
        MEDIA_URL = 'media/'
        MEDIA_ROOT = BASE_DIR / 'media'
        
        # Database
        DATABASES = {
            'default': {
                'ENGINE': 'django.db.backends.mysql',
                'NAME': SECRET['DB']['NAME'],               # 데이터베이스 이름
                'USER': SECRET['DB']['USER'],               # 사용자 이름
                'PASSWORD': SECRET['DB']['PASSWORD'],       # 비밀번호
                'HOST': SECRET['DB']['HOST'],               # 데이터베이스 서버 주소
                'PORT': SECRET['DB']['PORT'],               # MySQL의 기본 포트
            }
        }
    • ln -sf 대상파일 심볼릭링크파일 명령어를 사용하여 심볼릭 링크를 생성해주세요.
      # 예시
      # pwd가 프로젝트 루트 디렉터리 / config / settings 라고 가정
      ln -sf prod.py settings.py
      • .gitignore에 심볼릭 링크 파일을 추가하여 github에 추가되지 않도록 해주세요.
    • settings 폴더 아래의 settings.py를 불러와야 하기 때문에 manage.py 를 아래와 같이 수정해줍니다.
      # manage.py
      
      #!/usr/bin/env python
      """Django's command-line utility for administrative tasks."""
      import os
      import sys
      
      def main():
          """Run administrative tasks."""
          os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.settings')
          try:
              from django.core.management import execute_from_command_line
          except ImportError as exc:
              raise ImportError(
                  "Couldn't import Django. Are you sure it's installed and "
                  "available on your PYTHONPATH environment variable? Did you "
                  "forget to activate a virtual environment?"
              ) from exc
          execute_from_command_line(sys.argv)
      
      if __name__ == '__main__':
          main()
    • BASE_DIR 경로 수정
      BASE_DIR = Path(__file__).resolve().parent.parent.parent
  • djangorestframework-simplejwt 라이브러리 설치
    • config / settings.py
      • SIMPLE_JWT 설정을 추가하고 아래의 요구사항을 추가해주세요.
        • Access Token의 유효기간을 1시간으로 설정
        • Refresh Token의 유효기간을 1일로 설정
        • JWT 인증 시 last_login 필드가 datetime.now() 로 설정되도록 함
      • REST_FRAMEWORK 설정에 아래의 요구사항을 추가해주세요.
        • DEFAULT_AUTHENTICATION_CLASS 에 JWTAuthentication 추가하기

JWT 기반 로그인 기능 생성

class UserTokenLoginAPIView(TokenObtainPairView):
    permission_classes = [AllowAny]

class UserTokenLogoutAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        try :
            refresh_token = request.data['refresh']
            token = RefreshToken(refresh_token)
            token.blacklist() # 로그아웃시 못쓰게하려고
            return Response({"message": "Logout Successfully"}, status= status.HTTP_205_RESET_CONTENT)
        except Exception as e:
            return Response({"error": "Invalid Token"}, status= status.HTTP_400_BAD_REQUEST)

# users/urls.py
    path('login/', views.UserTokenLoginAPIView.as_view(), name='user-login'),
    path('logout/', views.UserTokenLogoutAPIView.as_view(), name='user-logout'),

5️⃣ PythonAnywhere 에 배포하기

  • pythonanywhere 에서 poetry 적용이 어려워 requiremnets.txt 생성
    poetry export --without-hashes --format=requirements.txt > requirements.txt
  1. console 에 접속하고, git clone 명령어를 사용하여 원격 레포지토리를 클론받습니다.
    특정 브랜치를 클론 받으려면 아래의 명령어를 사용하세요.

    ```markdown
    git clone --branch <branch_name> --single-branch <repository_url>
    ```
  2. cd 명령어로 프로젝트 루트 디렉터리 / config / settings로 이동하고, 다음 명령어를 사용하여 settings.py 심볼릭 링크 파일을 생성합니다.

    ln -sf prod.py settings.py
  3. 디렉터리에 requirements.txt 파일을 실행

    pip install -r requirement.txt
  4. static 파일 처리를 위해 STATIC_ROOT = '.static_root' 깃에 다시 push 한후 git pull

  5. python manage.py collectstatic 스태틱 파일 한곳으로 모는 명령어, ls -al 을 통해 확인(.static_root 생성)

  6. Source Code 부분 변경

    1. manage.py 있는 경로에서 pwd 입력후 위치 경로 복사
    2. 소스코드 부분에 복사 후 체크
  7. WSGI 부분 변경

  8. 에러는 error log 로 확인

  9. static 경로 넣기

1. 밑의 static files 부분
2. url /static/
3. directory -> 복사한 경로/.static_root
  1. 실행시 아래화면

0개의 댓글

관련 채용 정보