본문 바로가기
관리자

Programming-[Backend]/Django

Django로 프로젝트 세팅 with DRF, mysql, viewSet, pyTest

728x90
반응형

 

1. 프로젝트 생성, DB 연결

python 3.12.2, pyCharm을 통해서 프로젝트 생성

 

pip install poetry

poetry init

poetry add djangorestframework

poetry add django-environ

 

 

.env 파일을 만든다.

SECRET_KEY와 DB 정보 등을 .env에 숨긴다.

SECRET_KEY=..
DB_NAME=.
DB_PORT=.

DB_USER=.
DB_PASSWORD=.
DB_HOST=.

 

settings.py에 아래처럼 설정한다.

__file__은 코드가 작성된 현재 파일을 의미한다. os.path.abspath로 절대경로를 가져온 뒤, os.path.dirname으로 상위 경로의 디렉토리 이름을 BASE_DIR로 잡는다.

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
env = environ.Env()
environ.Env.read_env(env_file=f"{os.path.dirname(BASE_DIR)}/.env")

...
SECRET_KEY = os.environ["SECRET_KEY"]
...

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": ['templates']
        ...
    }
]

 

mysql인 경우

앱을 실행하면 mysqlclient를 설치할 수 없다고 한다. poetry를 통해 add를 할려고해도 에러가 뜬다.

다음 명령어를 입력한다.

poetry add pymysql

 

그리고 settings.py에 아래 내용을 추가해준다.

import pymysql

pymysql.install_as_MySQLdb()
--
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ["DB_HOST"],
        "PORT": os.environ["DB_PORT"],
    }
}

 

 

ALLOWED_HOSTS도 일단 허용 처리해준다.

ALLOWED_HOSTS = ["*"]

 

 

2. 모델 정의

 

대략 아래처럼 패키지 구조를 설정했다. 맨 처음엔 account app만 만든다.

 

python manage.py startapp account

 

settings.py의 INSTALLED_APPS 목록에 account를 추가한다.

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "account",
]

 

명령어로 앱을 만들어놓고, django가 만들어주는 파일에 아래 내용들을 작성한다.

 

Common 패키지

모든 모델에 들어갈 BaseModel을 정의한다. common 패키지 - model - base.py에 작성한다. SafeDelete와 created_at, modified_at, created_by, modified_by를 적용할 것이다.

 

SafeDelete

safe delete를 적용한다.

poetry add django-safedelete

 

 

TimeStampedModel

timeStampedModel을 상속받는다. 이를 위해 django-model-utils를 설치한다.

poetry add django-model-utils

 

 

BaseModel이 TimeStampedModel을 상속받도록하고, 필요하다면 모든 모델에 들어갈 공통 필드를 작성해준다. abstract로 둔다.

class BaseModel(TimeStampedModel):
    # 공통 필드 정의
    class Meta:
        abstract = True

 

이어서 BaseModel 아래에 SoftDeleteBaseModel도 작성해준다.

class SoftDeleteBaseModel(SafeDeleteModel, BaseModel):
	deleted_by_cascade = None

    class Meta:
        abstract = True

    def save(self, keep_deleted=False, **kwargs):
        super().save(keep_deleted=True, **kwargs)

 

deleted_by_cascade = None 옵션을 안주면 SofeDeleteBaseModel을 상속받는 모든 모델의 테이블에 deleted_by_cascade 컬럼이 남게된다. cascade(예를 들어 삭제 시, 연관된 다른 모델에 의해 해당 모델도 같이 삭제되는 경우)를 반드시 추적해야하는 경우가 아니라면 None 처리한다.

 

 

Account 앱

User

account 패키지의 models.py에 user 모델을 정의해준다. AbstractBaseUser, PermissionsMixin은 장고에서 기본적으로 제공해주는 기능을 갖는 클래스이다. set_password 메서드를 오버라이드해서 User에 password가 암호화되어 저장되도록 해준다. is_staff는 django의 auth에서 기본적으로 갖고 있는 필드로, override 해줘야한다.

class User(SoftDeleteBaseModel, AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(verbose_name="email", max_length=255, unique=True)
    first_name = models.CharField(max_length=20, blank=False, null=True)
    full_name = models.CharField(verbose_name="이름", max_length=20)
    is_staff = models.BooleanField(default=False)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["full_name"]

    objects = UserManager()

    class Meta:
        ordering = ("id",)
        indexes = [
            models.Index(fields=["email"]),
        ]

    def __str__(self):
        return f"이름: {self.full_name} | 이메일: {self.email}"

    def save(self, *args, **kwargs):
        if self.pk is None:
            self.email = self.email.lower()
        return super().save(*args, **kwargs)

    def set_password(self, raw_password):
        super().set_password(raw_password)

 

userManager도 manager 부분에 간단하게만 작성해준다.

class UserQuerySet(SafeDeleteQueryset):
    pass

class UserManager(SafeDeleteManager):
    _queryset_class = UserQuerySet

    def all(self, **kwargs) -> UserQuerySet:
        return super().all(**kwargs)

 

 

django-crum

이제 created_by, modified_by를 자동으로 입력하기 위해 django-crum 라이브러리를 설치한다.

poetry add django-crum

 

 

그리고 다시 common - model - base 부분에 RequestUserModel을 생성한다.

from crum import get_current_user
from django.conf import settings
from django.db import models

class RequestUserModel(models.Model):
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        related_name="%(class)s_created_by",
        default=None,
        db_column="created_by",
        on_delete=django.db.models.deletion.DO_NOTHING,
    )
    modified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        related_name="%(class)s_modified_by",
        default=None,
        db_column="modified_by",
        on_delete=django.db.models.deletion.DO_NOTHING,
    )

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        user = get_current_user()
        if user:
            if not user.pk or user.is_anonymous:
                user = None
            if not self.id:
                self.created_by = user
            self.modified_by = user
        super().save(*args, **kwargs)

 

settings에 AUTH_USER_MODEL의 위치를 알려줘야한다.

# settings.py

AUTH_USER_MODEL = "account.User"

 

 

 

migration

 

앱이 잘 실행되면 아래처럼 djangorestframework에서 추가한 앱들에 대한 migration이 필요하다고 뜬다.

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.

 

 

아래 명령어를 입력하고,

python manage.py makemigrations
python manage.py migrate

 

DB 툴에서 정상 생성을 확인한다.

 

 

베이스 모델 세팅이 끝났다. 이제 주문 도메인을 갖는다고 생각하고 order 모델을 만들어본다.

 

 

 

Order 모델

 

위와 마찬가지로 order도 앱을 만든다.

python manage.py startapp order

 

settings.py에 앱을 추가하는 것도 잊지말자

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "account",
    "order"
]

 

 

models.py에 Order를 정의한다. 위에서 정의했던 SofeDeleteBaseModel, RequestUserModel을 상속하여 safedelete가 가능하고 created, modified, created_by, modified_by가 입력될 수 있도록 한다. 또한 type, status로 조회가 많이 될 것을 예상하여 Meta에 indexes로 index를 잡아준다.

class Order(SoftDeleteBaseModel, RequestUserModel):

    name = models.CharField(max_length=191)
    description = models.TextField(blank=True, null=True)
    type = models.CharField(max_length=191)
    status = models.CharField(max_length=191)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.IntegerField()


    class Meta:
        ordering = ("id",)
        indexes = [
            models.Index(fields=["type"]),
            models.Index(fields=["status"]),
        ]

 

 

마이그레이션을 수행한다.

python manage.py makemigrations
python manage.py migrate

 

 

 

 

3. API 만들기

django DRF를 사용하여 api를 만든다.

 

먼저 작성 전, layer 구분을 위해 패키지를 추가한다.

 

 

List 테스트

List만 테스트 해본다. views.py에 viewSet을 작성한다.

from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet

from order.repository.models import Order
from order.service.order import OrderListSerializer

class OrderListViewSet(
    mixins.ListModelMixin,
    GenericViewSet
):
    queryset = Order.objects.all()
    serializer_class = OrderListSerializer

 

GenericViewSet은 해당 ViewSet에서 사용할 queryset, serializer_class를 정의할 수 있도록 해준다. 그리고 List만 test 할 것이기 때문에 mixins.ListModelMixin만 상속받는다.

 

service 레이어에 order.py를 만들고 아래 코드를 작성한다. 그냥 모든 필드값을 출력한다는 의미로 '__all__'을 사용했다.

from rest_framework import serializers

from order.repository.models import Order


class OrderListSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = '__all__'

 

urls.py로 가서 아래 코드를 작성한다. urlpatterns를 장고가 참조하는데, v1으로 구분하여 버전에 맞는 url을 분리해준다. as_view() 내부에는 GET 요청이 왔을 때, ViewSet에서 정의한 'list' 메서드에 연결하라고 정의해준다. 이렇게 하면 위에서 ViewSet에서 ListModelMixin을 정의했기 때문에 자동으로 연결된다. 마지막으로 해당 API를 내부적으로 부를 이름을 name에 정의해준다.

v1_urlpatterns = [
    path(r"orders/",  OrderListViewSet.as_view({"get": "list"}), name="order-list"),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("v1/", include((v1_urlpatterns, "v1"))),
]

 

 

이제 api를 불러보면 된다. 터미널에서 curl -v localhost:8000/v1/orders/로 요청하면 된다.

 

상태코드 200으로 수신되었으나, 데이터가 없어서 재미가 없다. GPT에게 DDL을 던져주고 데이터 넣는 명령어를 알려달라고 한다.

 

이 테이블에 들어갈 dummy data를 만들 수 있도록 insert into 구문을 작성해줘. 10개 정도면 좋겠어 create table order_order ( id bigint auto_increment primary key, created datetime(6) not null, modified datetime(6) not null, deleted datetime(6) null, name varchar(191) not null, description longtext null, type varchar(191) not null, status varchar(191) not null, price decimal(10, 2) not null, quantity int not null, created_by bigint null, modified_by bigint null, constraint order_order_created_by_7a60b2e5_fk_account_user_id foreign key (created_by) references account_user (id), constraint order_order_modified_by_ed7b6daf_fk_account_user_id foreign key (modified_by) references account_user (id) ); create index order_order_deleted_727b3ddd on order_order (deleted); create index order_order_type_status_dc9d8c74_idx on order_order (type, status);

 

 

질의 결과

INSERT INTO order_order (created, modified, name, description, type, status, price, quantity, created_by, modified_by)
VALUES 
    ('2024-03-05 10:00:00', '2024-03-05 10:00:00', 'Product 1', 'Description for Product 1', 'Product', 'Pending', 100.00, 1, NULL, NULL),
    ('2024-03-05 10:15:00', '2024-03-05 10:15:00', 'Product 2', 'Description for Product 2', 'Service', 'In Progress', 50.00, 2, NULL, NULL),
    ('2024-03-05 10:30:00', '2024-03-05 10:30:00', 'Product 3', 'Description for Product 3', 'Product', 'Completed', 75.00, 3, NULL, NULL),
    ('2024-03-05 10:45:00', '2024-03-05 10:45:00', 'Product 4', 'Description for Product 4', 'Service', 'Cancelled', 120.00, 1, NULL, NULL),
    ('2024-03-05 11:00:00', '2024-03-05 11:00:00', 'Product 5', 'Description for Product 5', 'Product', 'Pending', 90.00, 2, NULL, NULL),
    ('2024-03-05 11:15:00', '2024-03-05 11:15:00', 'Product 6', 'Description for Product 6', 'Service', 'In Progress', 60.00, 1, NULL, NULL),
    ('2024-03-05 11:30:00', '2024-03-05 11:30:00', 'Product 7', 'Description for Product 7', 'Product', 'Completed', 110.00, 2, NULL, NULL),
    ('2024-03-05 11:45:00', '2024-03-05 11:45:00', 'Product 8', 'Description for Product 8', 'Service', 'Cancelled', 80.00, 3, NULL, NULL),
    ('2024-03-05 12:00:00', '2024-03-05 12:00:00', 'Product 9', 'Description for Product 9', 'Product', 'Pending', 70.00, 1, NULL, NULL),
    ('2024-03-05 12:15:00', '2024-03-05 12:15:00', 'Product 10', 'Description for Product 10', 'Service', 'In Progress', 100.00, 2, NULL, NULL);

 

응답 결과

 

 

 

4. Test 만들기(PyTest)

 

pytest는 python이 제공하는 unittest, django가 제공하는 테스트에 비해

  • 단일 테스트에 유리하다.
  • 병렬 테스트로 진행되어 속도가 빠르다.

는 장점이 있다.

 

라이브러리를 설치한다. pytest-django를 설치하면 pytest가 함께 설치된다.

poetry add pytest-django

 

 

테스트를 위한 모델 생성 도구인 model-bakery를 설치한다.

poetry add model-bakery

 

 

코드 작성

이제 테스트 코드를 작성한다. 코드 분리를 위해 tests 패키지를 만든다. 그리고 예시로 repository 테스트를 하기 위해서 test_manager.py 파일을 만들었다. pytest 규칙상 'test_' 를 파일 이름 앞에 붙여줘야한다.

 

어떤 세팅을 참조할 것인지를 pytest.ini 파일을 만들어서 넣어줘야한다.

[pytest]
DJANGO_SETTINGS_MODULE=djangoProject.settings
python_files = tests.py test_*.py *_tests.py
adopts = --reuse-db
;adopts = --create-db

 

 

DJANGO_SETTINGS_MODULE에 settings.py 파일이 위치한 패키지와 settings을 적어주었다. 만약 local, dev, prod 환경으로 구분해서 settings를 만들어줬다면 그에 맞게 수정해주면 된다.

python_files로 pytest가 테스트를 수행할 파일명들의 규칙을 적어준다.

adopts 에 --reuse-db를 설정하여 db를 계속 사용한다. 만약 스키마가 변경된 경우 --create-db로 한번씩 실행해주면 된다.

 

 

테스트 파일

Arrange, Mock, Act, Assert 패턴을 사용했다. baker.make() 메서드를 이용해서 실제 모델을 만들어내는데, 이럴 경우 DB에 접근해야한다. pytest는 기본적으로 db 접근을 허용하지 않는다. 따라서 @pytest.mark.django_db 라는 mark를 사용해서 DB에 접근한다.

@pytest.mark.django_db
def test_filter_by_status():
    # Arrange
    target_status = 'Pending'
    other_status = ['In Progress', 'Cancel']
    # Mock
    baker.make(Order, status=target_status,)
    for status in other_status:
        baker.make(Order, status=status,)
    # Act
    result = Order.objects.all().filter_by_status_in(status=[target_status])
    # Assert
    assert len(result), 1
    assert result[0].status == target_status

 

가상환경 터미널에서 pytest를 실행하면, 아래와 같은 화면이 뜨면서 1 collected, passed가 되었다는 내용이 출력된다.

 

 

 

5. Admin

 

장고는 모델마다 자동으로 admin 페이지를 만들 수 있어서 편리하다. 너무 간단해서 문서를 조금만 읽어봐도 바로 알 수 있을 정도다.

https://developer.mozilla.org/ko/docs/Learn/Server-side/Django/Admin_site

 

 

admin을 만들기 위해서 모델을 admin에 등록한다. order/admin.py에 아래 내용을 적는다.

from django.contrib import admin

from order.repository.models import Order

# Register your models here.
admin.site.register(Order)

 

그리고 user 기능을 account 패키지로 따로 뺐으므로, User model에 기반한 UserManager에 아래 내용들을 적어준다. get_by_natural_key, create_superuser 메서드는 admin에 접속할 수 있는 superuser를 만들기 위한 method이고, override하는 형태이다.

class UserManager(SafeDeleteManager):
    _queryset_class = UserQuerySet

    def all(self, **kwargs) -> UserQuerySet:
        return super().all(**kwargs)

    def get_by_natural_key(self, username):
        return self.get(**{self.model.USERNAME_FIELD + "__iexact": username})

    def create_user(self, email, full_name, password=None, **extra_fields):
        if not email:
            raise ValueError("Email must be set")
        user = self.model(email=email, full_name=full_name, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, full_name, password, **extra_fields):
        extra_fields.setdefault("is_superuser", True)

        if extra_fields.get("is_superuser") is not True:
            raise ValueError("Superuser must have is_superuser=True.")

        return self.create_user(email, full_name, password, **extra_fields)

 

 

 

명령어를 입력하고, email, password를 입력하여 superuser를 만든다. 이메일은 안적어도 되고, password는 8자리 이상이여야한다.

python3 manage.py createsuperuser

 

 

그리고 localhost:8000/admin에 접속하여 로그인하면 아래와 같은 화면을 볼 수 있다.

 

 

상세 설정하기

각 모델에 대해서 조금 더 상세한 설정을 할 수 있다. 이를 위해서 기존 한 줄 방식 대신 class형으로 model을 선언한다.

# admin.site.register(Order)
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    pass

 

이렇게 하면 기본적으로 list 뷰를 보여준다.

 

 

list_display 속성을 통해 목록에서 보여줄 column들을 설정할 수 있다.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'quantity', 'price', 'created',]

 

 

 

list_filter 속성으로 필터를 추가할 수 있다.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'quantity', 'price', 'created',]
    list_filter = ['status']

 

 

각 detail 뷰에서는 fields, field_sets 옵션으로 표시할 옵션 들을 설정할 수 있다. 자세한 기록은 불필요할 것 같다. 공식문서를 찾아보면 된다.

 

 

1:N 모델 관리

리스트에서 1:N으로 연관되어진 모델을 표시하는 것은 금지된다. N+1 문제처럼 과도한 로우가 생성될 수 있기 때문이다. 그래서 공식 문서에서는 따로 함수를 만들고 그 값을 field로 표시하라고 한다.

 

 

그리고 만약 연관된 모델을 한 화면에서 같이 보고 싶다면 inlines 를 선언하여 추가하라고 한다.

728x90
반응형