Django admin custom (2) - 장고도 UX 개선을 해야한다! custom list & detail view, custom field & filter 와 추가 라이브러리

정현우·2023년 9월 21일
4

Django Basic to Advanced

목록 보기
12/40
post-thumbnail

[ django admin을 다양하게 custom하는 것을 확인하고 기록하기, model 대상의 다양한 action 들과 list view의 look and feel, 써드파티까지 핵심 위주로 살펴보기 ]

Django admin UX 개선하기!

바로 앞 게시글과는 다르게 "잠깐 튜토리얼을 이어가지 않는다." 간단한 프로젝트로 django에서 admin custom을 하는 접근방법에 대해 알아보자. 더 다양한 Detail form, Search, Filter, Custom Template 를 다룰 것이다. 장고 튜토리얼을 이어가는 글은 여기서 부터 다시 시작 한다.

🔥 완성된 프로젝트 파일 다운로드 받기 🔥

1. 일단 빠르게 Project init하기

  • 기본 장고 프로젝트 세팅 설정에 대한 설명은 모두 skip하겠다. 만약 필요하다면 해당 시리즈의 제일 앞선 글들을 추천한다.

1) django project init

# 디렉토리 생성 
mkdir django-admin-custom
cd django-admin-custom

# python 가상환경 & 라이브러리 세팅
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install django
pip install django-environ

# django 프로젝트 세팅 (local sql-lite 사용함)
django-admin startproject config .
python manage.py startapp shopping
python manage.py makemigrations
python manage.py migrate

# super user 만들기
DJANGO_SUPERUSER_PASSWORD=admin123! python manage.py createsuperuser --username=admin --email=admin@example.com --noinput
  • settings.py 는 아래 부분만 조금 변경하자!
# 제일 위에서 아래 코드 삽입
import environ

env = environ.Env()
ADMIN_URL = env('ADMIN_URL', default='admin/')

# ...생략...

INSTALLED_APPS = [
	# ...생략...
    'shopping',
]

# ...생략...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'], # 여기 디렉토리 잡아주기
		# ... 생략 ...
    },
]
  • urls.py 는 아래 부분만 조금 변경하자!
from django.conf import settings  
# ...생략...

urlpatterns = [
    path(settings.ADMIN_URL, admin.site.urls),
    # ...생략...
]
  • 프로젝트 구조는 아래와 같다.
.
├── config
│   ├── __init__.py
│   ├── __pycache__
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
├── manage.py
└── shopping
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── migrations
    ├── models.py
    ├── tests.py
    └── views.py

# 4 directories, 13 files

2) models

  • 편의를 위해 파일 분리 없이, enum이 되어줄 필드 또 help text등은 모두 skip되어 있다. 실제 프로젝트에서는 아래보다 훨씬 많은 내용과 설정이 필요하다.
from collections import namedtuple

from django.db import models
from django.utils import timezone


ORDER_STATUSES = namedtuple('ORDER_STATUSES', 'new processing shipped complete canceled')._make(range(5))

ORDER_STATUSES_CHOICES = (
    (ORDER_STATUSES.new, 'New'),
    (ORDER_STATUSES.processing, 'Processing'),
    (ORDER_STATUSES.shipped, 'Shipped'),
    (ORDER_STATUSES.complete, 'Complete'),
    (ORDER_STATUSES.canceled, 'Canceled'),
)


class Category(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    is_active = models.BooleanField(default=True, db_index=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'category'
        verbose_name_plural = 'categories'

    def __str__(self):
        return self.name


class Product(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    category = models.ManyToManyField(Category, related_name='products')
    is_active = models.BooleanField(default=True, db_index=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'product'
        verbose_name_plural = 'products'

    def __str__(self):
        return self.name


class Customer(models.Model):
    first_name = models.CharField(max_length=200)
    last_name = models.CharField(max_length=200)
    phone = models.CharField(max_length=200, unique=True)

    class Meta:
        ordering = ('first_name',)
        verbose_name = 'customer'
        verbose_name_plural = 'customers'

    def __str__(self):
        return ' '.join([self.first_name, self.last_name])


class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    created_dt = models.DateTimeField(auto_now_add=True)
    completed_dt = models.DateTimeField(null=True, blank=True)
    status = models.IntegerField(default=ORDER_STATUSES.new, choices=ORDER_STATUSES_CHOICES)

    class Meta:
        ordering = ('-created_dt',)
        verbose_name = 'order'
        verbose_name_plural = 'orders'

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        if self.completed_dt:
            self.status = ORDER_STATUSES.complete
        if self.status == ORDER_STATUSES.complete and not self.completed_dt:
            self.completed_dt = timezone.now()
        super().save(*args, **kwargs)


class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField(default=1)

    class Meta:
        verbose_name = 'order item'
        verbose_name_plural = 'order items'
        unique_together = ('order', 'product')

    def __str__(self):
        return self.product.name
  • 모델의 관계도는 아래와 같다. (mermaid 활용) 화살표는 "정참조하고 있는 방향" 이라고 보면 된다.

  • 당연히 python manage.py makemigrations & migrate 를 하고 진행하자.

3) 기본 admin.py 세팅

from django.contrib import admin
from .models import Category, Product, Customer, Order


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'is_active', 'id',)

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'is_active', 'id',)

@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
    list_display = ('first_name', 'last_name', 'phone', 'id',)

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)
  • python manage.py runserver 로 아래와 같이 보이면 기본 세팅은 완료다.


2. Model list & detail veiw

  • 일단 list view 는 특정 model의 admin class list_display attribute를 수정해서 display되는 컬럼을 컨트롤할 수 있다. 그리고 list_per_page 를 통해 pagination의 수를 바꿀 수 있다. list_display_links 값을 통해서는 어떤 컬럼을 클릭해서 "해당 모델의 detail view로 접근하게 할 것인지" 설정할 수 있다.

  • 그리고 ordering attribute를 통해 기본 정렬 순서를 세팅할 수 있다. 하지만 Model의 Meta를 통해서도 세팅이 가능하다. 이 경우 queryset의 default ordering 도 따라가니 유의!

1) prepopulated_fields

  • django official docs 에서 확인가능한 prepopulated_fields 는 이 옵션을 사용하면, 다른 필드의 값에 기반하여 특정 필드의 값을 자동으로 생성하고 채울 수 있다. 특히 slug를 사용할때 유용하다!
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    list_per_page = 10
    ordering = ('name',)

  • CharFieldSlugField에만 사용할 수 있고 이미 저장된 객체의 값을 업데이트하지 않는다! (나중에 적용할 땐 직접 마이그레이션 해야한다.) 내부적으로는 해당 모델의 __str__ 메서드를 사용하여 문자열로 변환한다!

2) filter_horizontal 활용하기

  • filter_horizontal 은 Django Admin에서 ManyToManyField 를 사용자 친화적으로 표시하기 위한 옵션값이다.
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id',)
    filter_horizontal = ('category',)
  • 다음 사진은 차례로 filter_horizontal 적용 전과 후의 비교 사진이다!

3) TabularInline or StackedInline 사용하기

  • django가 model 중심적이다 보니 하나의 모델에서 관계를 가지고 있는 다양한 모델을 동시에 편집하고 싶은 사용자 욕구가 무조건 있다.

  • Order 모델은 OrderItem 을 역참조하고 있다. customer의 하나 주문에 다양한 order item이 포함되는데 admin이 order에서 그 item 리스트를 직접 보거나 편집할 수 있게 하면 편할 것이다. 그때 사용하면 좋는 것이 TabularInlineStackedInline 이다.

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    extra = 1  # 이 값을 설정하여 추가로 보여질 빈 폼의 수를 정의합니다.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)
    inlines = [OrderItemInline]

  • 위 코드와 사진과 같이 특정 Order model에 접근하면 해당 Order를 참조(부모로 두고 있는) 하는 OrderItem 이 모두 list 형태로 보인다.

  • StackedInline 를 사용하면 list table 형태가 아니라 아래 사진과 같이 look & feel 만 바뀐다.

  • search_fields attribute를 통해 like 검색 대상 필드를 설정할 수 있다. 이 경우 꼭 고려해야 할 부분은 like query 이기 때문에 퍼포먼스가 중요하다면 heavy한 query 피해야 한다!
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
	# ...생략...
    search_fields = ('name',) # 추가
  • search_files 의 대상 필드 값만 추가해도 django core에서 url-querystring 값을 변경해 가며 queryset 을 filtering 한 결과값을 보여준다.

  • djangoql 이라는 라이브러리로 search_fields 기능을 더 확장해서 아주 쉽게 쓰는 방법이 있다. 추가 설치를 해야한다 - pip install djangoql 그리고 settings.pyINSTALLED_APPSdjangoql 를 추가해줘야 한다. 그리고 admin class가 상속만 받으면 된다.
from djangoql.admin import DjangoQLSearchMixin

@admin.register(Product)
class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
	# ...생략...

  • 이렇게 model의 어떤 field를 search할지 선택할 수 있게 된다. 진짜 가장 강력한 부분은 search "연산자를 선택할 수 있게 된다는 것" 이다. 기본적으로 like query만 던지던 search_fields 에 비해 사용자의 아주 다양한 선택권이 생긴 것이다. search_fields 와 같이 사용한다면 새롭게 생긴 search input box 앞에 있는 check box를 통해 껏다 켯다 할 수 있다.

5) custom filter

  • 개인적으로 django admin을 정말 잘 쓸생각이 있다면 해당 부분이 back office를 직접 사용하는 사용자에게 가장 유용하지 않을까 생각이 든다.

  • admin은 검색보다 빠르게 특정 field의 값으로 필터링을 하고싶을 수 있다. 가령 Order model에서 status 여부로 말이다. 이때 사용하기 좋은 filter는 list_filter 이다.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('customer', 'created_dt', 'completed_dt', 'status', 'id',)
    list_filter = ('status',)
    inlines = [OrderItemInline]

  • 유의해야할 점은 list_filter를 거는 field 대상으로 가능한 모든 값을 가져와서 filter를 만들어주기 때문에 choice나 enum을 사용하는 값이 아니라 자유입력 값이라면 굉장한 오버헤드를 유발할 수 있다!

  • 그리고 admin.SimpleListFilter 를 오버라이딩 해서 filter 기능 자체를 full custom할 수 있다.

class OnlyActiveOrdersFilter(admin.SimpleListFilter):
    title = 'Show Only Active Orders'
    parameter_name = 'status'

    def lookups(self, request, model_admin):
        return (
            ('active', 'Active'),
        )

    def queryset(self, request, queryset):
        if self.value() == 'active':
            return queryset.filter(status__in=(ORDER_STATUSES.new, ORDER_STATUSES.processing, ORDER_STATUSES.shipped))
        return queryset

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
	# ...생략...
    list_filter = ('status', OnlyActiveOrdersFilter)

  • title은 filter에 대한 설명이고 parameter_name 는 filter도 url querystring 값을 기반으로 하기때문에 url에 들어갈 파라미터 값을 의미한다. 꼭 field와 같을 필요는 없지만 혼란을 주지 않으려면 같은게 좋다.

  • lookups method를 통해 filter 되는 section의 선택지를 세팅하고 queryset method에서 실제로 어떤 queryset을 구성할지 ORM을 세팅하면 된다!

  • 참고로 custom filter를 통해 Date, Datetime field를 filtering 할때는 꼭 형변환(timestamp casting등, 문자열에서 날짜 등)이 이뤄지지 않는지 재차 제대로 확인해보자! 안그러면 filter 에서 엄청 오버헤드가 발생할 수 있다.

6) model custom field 추가하기 (Custom Columns)

  • admin이 back-office에서 보고싶은 값은 model 의 모든 컬럼이 아닐 수 있으며, 모델이 가지는 값은 아니지만 추가적으로 알아보고 싶은 값이 있을 수 있다.

  • 가령 user 정보를 저장하는 Model 이 있다고 가정한다면, 주민등록 번호 뒷자리 시작으로 여자인지 남자인지, 외국인인지 내국인인지 구분이 가능하며 admin은 back-office에서 해당 필드를 직접 쉽게 보고싶을 수 있는 것이다.

  • 여기서는 Product model의 active 값을 좀 더 admin에게 친화적으로 표현하는 것으로 바꿔보자.

from django.utils.html import format_html
# ...생략...

@admin.register(Product)
class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    list_display = ('name', 'slug', 'is_active', 'id', 'highlighted_active')
    filter_horizontal = ('category',)
    search_fields = ('name',)
    
    @admin.display(ordering='is_active', description='Is Active')
    def highlighted_active(self, obj):
        return format_html('<span style="color: {};">{}</span>',
                           'green' if obj.is_active else 'red',
                           'Active' if obj.is_active else 'Inactive')

  • format_html 은 django admin에서 특정 부분을 html tag 사용하고 싶을때 사용하는 것이다. @admin.display 데코레이터를 통해서 highlighted_active 라는 custom field를 만들었고 해당 method는 무조건 obj (target model) 파라미터를 가진다! 그리고 해당 method 이름으로 list_display attribute에 추가해주면 끝이다!

참조하는 모델을 직접 Custom Column 으로 만들기

  • ProductCategory 와 N:M 관계이다. @admin.display 를 활용해 list display에서 category를 쉽게 보여줄 수 있다.
@admin.register(Product)
class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
    list_display = ('name', 'slug', 'is_active', 'id', 'highlighted_active', 'display_category')
	
    #...생략...
    
    @admin.display(description='Categories')
    def display_category(self, obj):
        return ", ".join([category.name for category in obj.category.all()])
    
    def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
        return super().get_queryset(request).prefetch_related('category')

  • get_queryset 를 오버라이딩한 이유는 prefetch_related 로 N+1 query issue를 막고 최적화를 하기 위해서다. prefetch_related 로 가져오지 않으면 category 값을 가져오기 위해 한 model 당 category table에서 찾는 query가 발생한다. 이게 옵션처럼 보이지만 사실 엔터프라이즈에서는 필수다! 이런 기본적인 최적화는 기본 소양이다!

  • 추가로 이런 N:M 이 아닌 FK, OO(OneToOne) 관계라면 get_queryset 대신 class에 list_select_related = True attribute 를 추가하면 알아서 최적화 쿼리셋으로 가져온다.

7) list display에 표기되는 FK model 바로가기 만들기

  • 당연하게도 model은 다양한 관계를 가지고 model 중심 admin인 django는 관계를 가지는 model을 찾기위해 또 다른 메뉴에 들어가서 검색하는 일련의 과정이 필요하며 굉장히 admin 입장에서는 불편하다.

  • 그냥 관계를 가지는 그 값을 클릭하면 바로 해당 Model detail로 가면 좋지 않을까? 당연히 구현하는 방법은 다양하다. 사실 custom field display 를 응용하는게 외부 디펜던시가 없기 때문에 가장 깔끔하다.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('link_to_customer', 'created_dt', 'completed_dt', 'status', 'id')
    list_filter = ('status', OnlyActiveOrdersFilter)
    list_display_links = ('created_dt', 'id')
    inlines = [OrderItemInline]

    @admin.display(description='Customer')
    def link_to_customer(self, obj):
        link = reverse("admin:shopping_customer_change", args=[obj.customer.id])
        return format_html('<a href="{}">{}</a>', link, obj.customer)

  • reverse 함수는 "URL 패턴의 이름을 기반으로" URL 문자열을 동적으로 생성해준더. django에서 admin의 url pattern 이 있고 admin:<app_label>_<model_name>_change 로 구성된다. 이 점을 활용해 위와 같이 구성하면 쉽게 model link로 가게할 수 있다.

  • 근데 오히려 admin에게 ?? 라는 물음표를 안겨줄 수 있으므로 링크된다는 이미지 (ex - 🔗, ➡️ 등) 을 활용하면 아주 좋을 것이다.

외부 라이브러리 사용하기

  • 추가 라이브러리 설치 로 더 심플하게 만드는 방법도 있다. 해당 건은 이미 위와 비슷하게 만들어진 형태로 change_links attribute를 변경해서 관계 model link를 만들 수 있다. 사용법이 simple 함으로 링크만 걸어두겠다.

8) custom template으로 모델 통계 추가하기

  • admin 사용자를 배려한다면 자주 통계 대상이 되는 필드, 컬럼 값들은 미리 admin page에서 보여주면 얼마나 좋은가! 지금 프로젝트 예시로, Product admin에서 category 별로 등록된 product 의 개수를 보여주게 만들어 보자!

(1) tempalte 디렉토리 추가

  • 초기 project 세팅했을때 template 디렉토리 세팅을 했고, 이제 project root 경로에 templates 디렉토리를 만들고 admin 디렉토리까지 추가해 주자. 그리고 custom_template.html 파일을 만들어 주자
└── templates
    └── admin
        └── product_change_list_template.html

(2) custom_template.html 만들기

{% extends "admin/change_list.html" %}

{% block content %}
{{ block.super }}  {# 기존의 모든 컨텐츠를 유지 #}

<table style="width:100%;">
    <thead>
        <tr>
            <th>Category</th>
            <th>Product Count</th>
        </tr>
    </thead>
    <tbody>
        {% for category, count in category_product_counts %}
            <tr>
                <td>{{ category }}</td>
                <td>{{ count }}</td>
            </tr>
        {% endfor %}
    </tbody>
</table>
{% endblock %}
  • extends "admin/change_list.html"block.super 로 기존 admin template을 상속받은 뒤에 우린 table tag만 추가할 것이다.

(3) admin class changelist_view 오버라이딩 하기

from django.db.models import Count

@admin.register(Product)
class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
	# ...생략...
    change_list_template = 'admin/product_change_list_template.html'

    def changelist_view(self, request, extra_context=None):
        # Category별 Product 개수 집계
        category_product_counts = Category.objects.annotate(product_count=Count('products')).values_list('name', 'product_count')
        
        extra_context = extra_context or {}
        extra_context['category_product_counts'] = category_product_counts
        
        return super().changelist_view(request, extra_context=extra_context)
  • change_list_template 로 우리가 만든 template html을 사용하게 한 뒤 changelist_view method를 오버라이딩 하고 여기서 핵심적으로 통계 queryset을 구성하면 된다.

  • 사실 이건 좀 고민해봐야한다. 마냥 모든 model 마다 통계 template이 있으면 우리의 DBMS는 lock 걸려서 허우적거릴 가능성이 매우 크다. 거의 무료티어일 가능성이 높지 않은가 ㅠㅜ 사실 django admin 최적화 대한 글을 진짜 열심히 2주간 작성했었는데 다른 컴퓨터에서 이어서 임시저장하다가 다 날라가버렸다. 그 이후 의욕을 잃었다..

  • 정말 볼륨이 있는 table 대상으로 하고 싶다면 django에서는 model 분리가 조금 필요하다. client의 restAPI / formAPI 등에서 select로만 활용할 model, 그리고 admin에서 control할 model, 마지막으로 통계전용 model이 필요하다. 이게 규모가 꽤 커지면 취해야할 스탠스다.

위에 예시들 외에도 official docs에서 ModelAdmin class의 attribute들을 살펴보면 생각보다 엄청 다양하게 활용할 수 있는 값이 많다!

원래 action, in-line action, excel import & export 까지 다루려고 했으나 너무 방대한 내용을 포괄하는 것 같아 분리한다! 해당 내용은 바로 이어지는 다음글에서 볼 수 있다.


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글