Django admin custom (3) - 장고도 UX 개선을 해야한다! Action & In-line action, Excel import & export, Base Admin Model 과 추가 라이브러리

정현우·2023년 9월 21일
6

Django Basic to Advanced

목록 보기
13/38

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

Django admin UX 개선하기!

바로 앞 게시글에 이어서 간단한 프로젝트로 django에서 admin custom을 하는 접근방법에 대해 알아보자. Action, In-line action, Excel import & export 를 다룰 것이다. 튜토리얼을 이어가려면 다음글로!!

🔥 동일 시리즈 내의 바로 앞글과 이어지는 글입니다. Django admin custom (2) - 장고도 UX 개선을 해야한다! custom list & detail view, custom field & filter 와 추가 라이브러리 글 먼저 확인 부탁드리겠습니다!

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

1. Model Action

Django Admin에서 Action은 관리자 페이지의 목록 뷰(list view)에서 여러 객체에 대한 일괄 작업을 수행할 수 있게 해주는 기능이다. 예를 들어, 여러 사용자를 한 번에 활성화 또는 비활성화하거나, 여러 객체를 삭제하는 등의 작업을 수행할 수 있다. 특히 worker (celery 등) 와 함께 사용해 django admin의 확장성과 활용성을 한 차원 끌어올려 준다!

1) Action

  • Product admin에서 체크한 모든 상품을 inactive 하게 만드는 action을 만들어 보자.
from django.contrib import admin, messages

@admin.register(Product)
class ProductAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
	# ...생략...
    actions = ('make_inactive',)
    
    def make_inactive(self, request, queryset):
        updated_count = queryset.update(is_active=False)
        # admin에게 비활성화된 상품의 개수를 알립니다.
        return messages.success(request, f"{updated_count} 개의 상품이 비활성화되었습니다.")
	make_inactive.short_description = "선택한 상품 비활성화하기"

  • 위와 같이 actions attribute에 action으로 사용할 function만 추가해주면 바로 action으로 사용이 가능하다! 그리고 messages object를 통해서 admin에게 특정 messge를 전달할 수 있다.

  • 이런 흐름으로 worker(celery 등) 에게 task를 던지는 형태로 만들면 back-office를 좀 더 고급지게 만들 수 있고 무거운 작업도 충분히 가능하게 만들 수 있다.

2) In-line Action

  • 체크해서 queryset 대상으로 action하는게 아니라 하나의 레코드(record, row) 대상으로 button을 통해 action을 할 수 있다. 결국 같은 action 개념인데 in-line으로 박혀있는 것이다.

  • 직접 구현하려면, 다양한 방법이 있지만, 정석적인 방법은 custom template html 만들어서 <a href="{% url 'admin:make-product-inactive' product.id %}">Make Inactive</a> 태그 추가하고 해당 url에 맞는 view 만드는 방법 이 있다. 사실 굉장히 귀찮다. 그래서 대게 django-inline-actions 이라는 외부라이브러리를 사용하는 것 같다.

  • pip install django-inline-actions 로 설치하고 settings.pyINSTALLED_APPSinline_actions 을 추가하자!

  • 참고로 django 4 이상을 사용하고 있다면 해당 라이브러리에서 ImportError: cannot import name 'ugettext_lazy' from 'django.utils.translation' 가 날 것이다. (23.09.21 기준) light한 라이브러리 이지만 사용자도 커뮤니티도 꽤 구성되어 있는데 2년 전부터 버전 관리가 안되어 있다..

  • 그래서 직접 패키지 코드를 바꾸는게 편하다.. 패키지경로/inline_actions/admin.py 에서 import하는 부분만 바꾸면 된다. (아래 사진 참조)

  • 엔터프라이즈라면 해당 버전을 fork 떠서 따로 repo 관리하면서 version을 strict 하게 관리해야한다는 점! 잊지말아야 한다!

기본적인 사용법

  • docs를 보면 생각보다 다양한 사용법이 있다. 직접 class를 만들어서 구현하기 (재사용성을 위해), 가볍게 method만 추가하기 등이 대표적이다. 그 경우 아래 method 를 오버라이딩 하면 된다.

from inline_actions.admin import InlineActionsModelAdminMixin

@admin.register(Product)
class ProductAdmin(InlineActionsModelAdminMixin, DjangoQLSearchMixin, admin.ModelAdmin):
	# ...생략...
    inline_actions = ['make_inactive_inline']
    # ...생략...
    
    def make_inactive_inline(self, request, obj, parent_obj=None):
        if obj.is_active:
            obj.is_active = False
        else:
            obj.is_active = True
        msg = "비활성화" if obj.is_active else "활성화"
        obj.save()
        return messages.success(request, f"{obj.name} 상품이 {msg} 되었습니다.")
    make_inactive_inline.short_description = "활성상태 토글"

  • 위 사진과 같이 "활성상태 토글" 이라는 inline button이 만들어졌고, inline_actions attribute에 바인딩되는 inline action method만 추가해주면 모든게 끝이다!

위에서 언급했듯이 지금 해당 라이브러리는 django 4 이상 지원은 비공식 지원이니, 엔터프라이즈 환경이라면 직접 해당 라이브러리를 따로 만들어서 진행하는 것을 추천한다.


2. Excel Import & Export

  • 생각보다 admin이 은근 이용하게 되며, 은근 니즈가 있는 기능이다. 특히 django 특성상 model 중심적이다 보니 오히려 이 excel이랑 호흡이나 합이 굉장히 잘 맞다. 그래서 그런지 django 진영에서 유명한 써드파티가 하나 있다. 바로 "django-import-export" 이다.

1) django-import-export

  • 다양한 데이터 포맷 (CSV, JSON, Excel 등)을 지원하며 Admin에서 데이터를 import/export 할 수 있게 해주는 라이브러리이다. 깃허브 레포 링크

  • pip install django-import-export 로 설치하고 settings.pyINSTALLED_APPSimport_export 를 추가한다. 기본 세팅은 완료! official docs 에서 다양한 설정값에 대해 확인할 수 있다.

기본적인 사용법

  • example model로 전체 구조가 아래와 같이 구성된다.
# admin.py
from import_export.admin import ImportExportModelAdmin
from django.contrib import admin
from .models import MyModel

class MyModelResource(resources.ModelResource):
    class Meta:
        model = MyModel
        fields = ('id', 'name', 'value',)

@admin.register(MyModel)
class MyModelAdmin(ImportExportModelAdmin):
    resource_class = MyModelResource
  1. resources.ModelResource 를 상속받는 class가 django ORM model 대상으로 "추출하고 싶은 field를 정리한 형태"로 구성한다.

  2. 그런 다음 지금까지 우리가 했던것과 같이 admin register class에서 ImportExportModelAdmin 를 상속받고 resource_class attribute를 1에서 구현한 Resource class로 mapping 해주면 끝이다.

2) import & export

  • 실제로 Product model 대상으로 import & export를 만들어 보자!
class ProductResource(resources.ModelResource):
    class Meta:
        model = Product
        fields = ('name', 'slug', 'is_active', 'id',)

@admin.register(Product)
class ProductAdmin(ImportExportMixin, InlineActionsModelAdminMixin, DjangoQLSearchMixin, admin.ModelAdmin):
	# ...생략...
    resource_classes = (ProductResource,)
  • 앞서 알아본 기본 사용법과 동일하게 ImportExportMixin 상속받고 resource_classes attribute를 만든 Resource class를 mapping 하면 끝이다! 그러면 아래와 사진과 같이 admin에서 바로 확인가능하다!

  • 생각보다 아주 다양하게 활용범위가 넓다. 앞선 시리즈 글에 이어 진짜 다양하게 넓은 범위로 django admin의 부족한 UX을 키우기 위한 설정들을 살펴봤다.

  • 이쯤되면 느껴지는게 바로 admin에 등록할때 기본적으로 넣고 싶은 기능을 포함하고 있는 Base Admin Class 를 만들고 싶은 욕구가 생긴다.


3. 통합 기능 Base Admin과 다른 써드 파티

1) Base Admin Class

  • 아래 4가지 기능 정도를 우리만의 default custom base admin 으로 삼을 수 있다.
  1. custom search 에서 사용했던 DjangoQLSearchMixin
  2. FK 또는 O2O 관계를 가지는 Model detail view로 바로가게 했던 link_to_customer
  3. action과 inline action을 좀 더 편하게 등록하게 하기
  4. 바로 이전에 살펴본 django-import-export
from typing import List, Tuple

from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html

from inline_actions.admin import InlineActionsModelAdminMixin
from import_export.admin import ImportExportMixin
from djangoql.admin import DjangoQLSearchMixin

class ModelAdminBase(
    ImportExportMixin, 
    InlineActionsModelAdminMixin,
    DjangoQLSearchMixin,
    admin.ModelAdmin
    ):
    """Base ModelAdmin class with enhancements for Django Admin."""
    
    list_per_page: int = 20  # pagination 의 default 단위 세팅
    link_to_model: List[Tuple[str, str, str]] = []  # FK 또는 O2O 관계를 가지는 Model detail view로
    inline_actions: List[str] = []  # for inline action
    resource_classes: List[str] = []  # for django-import-export

    @staticmethod
    def short_des(description: str):
        """Decorator to set short_description attribute to a method."""
        def decorator(func):
            func.short_description = description
            return func
        return decorator

    def _create_link_method(self, field_name: str, target_app_label: str, target_model_name: str):
        """Create a link method dynamically."""
        def link_method(obj):
            related_obj = getattr(obj, field_name)
            if getattr(related_obj, "id", None):
                link = reverse(f'admin:{target_app_label}_{target_model_name}_change', args=[related_obj.id])
                return format_html('<a href="{}">🔗{}</a>', link, related_obj)
            else:
                return "-"
        link_method.short_description = field_name
        return link_method
    
    def set_link_to_model(self):
        """Set link to model dynamically."""
        for field_name, target_app_label, target_model_name in self.link_to_model:
            method_name = f"link_to_{field_name}"
            link_method = self._create_link_method(field_name, target_app_label, target_model_name)
            setattr(self, method_name, link_method)
            if isinstance(self.list_display, (list, tuple)):
                self.list_display = list(self.list_display)  # Convert to list if it's a tuple
                self.list_display.append(method_name)

    def __init__(self, *args, **kwargs):
        """Initialize the ModelAdminBase instance."""
        super().__init__(*args, **kwargs)
        if not self.resource_classes:
            raise NotImplementedError(f"{self.__class__.__name__} must define 'resource_classes'")
        self.set_link_to_model()

톺아보기

  • 최대한 light하게 만드려고 했는데 욕심이 조금 커진 base model이다. 상속받는 class에서는 class attribute만 세팅하면 모두 되게끔 하는게 목표다.

  • 생성자(__init__)를 오버라이딩해서 django-import-export 를 위한 field값 지정이 비어있거나 attribute가 존재하지 않으면 NotImplementedError 가 나도록 했다.

  • action과 in-line action에서 function description을 데코레이터로 쓸 수 있게 staticmethod, short_des 를 추가했다.

  • 핵심은 set_link_to_model 이다. 생성자에서 호출하고 link_to_model 모델값만 세팅해 놓으면 관계를 가진 해당 모델의 detail view로 바로가게끔 만들었다. 그러기 위해 상속받고 구현하는 하위(자식) class에서는 link_to_model = [('customer', 'shopping', 'customer')] 와 같은 쌍으로 선언해야 한다. 실제 적용하는 부분을 한 번 보면 바로 와닿을 것이다.

2) Product에 적용하기

  • 위에서 2번 항목을 테스트하기 위해 일단 product에 강제로 FK를 하나 추가해줘야 한다.
class DummyModel(models.Model):
    remark = models.CharField(max_length=100)
    
    def __str__(self):
        return self.remark

class Product(models.Model):
	# ...생략...
    dummy = models.ForeignKey(
        DummyModel, 
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        default=None
    )
	# ...생략...
  • 그리고 기존 PorductAdmin class를 완전 바꿔버리자!
from base import ModelAdminBase

@admin.register(Product)
class ProductAdmin(ModelAdminBase):
    # list view config
    list_display = ('name', 'slug', 'is_active', 'id', 'highlighted_active', 'display_category')
    search_fields = ('name',)
    link_to_model = [('dummy', 'shopping', 'dummymodel')]
    resource_classes = (ProductResource,)
    
    # detail view config
    prepopulated_fields = {'slug': ('name',)}
    filter_horizontal = ('category',)
    
    # actinos
    actions = ('make_inactive',)
    inline_actions = ('make_inactive_inline',)
    
    # ========================================================= #
    # List view fields
    # ========================================================= #
    
    @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')
    
    @admin.display(description='Categories')
    def display_category(self, obj):
        return ", ".join([category.name for category in obj.category.all()])

    # ========================================================= #
    # actions
    # ========================================================= #
    
    @ModelAdminBase.short_des("선택한 상품 비활성화하기")
    def make_inactive(self, request, queryset):
        updated_count = queryset.update(is_active=False)
        # admin에게 비활성화된 상품의 개수를 알립니다.
        return messages.success(request, f"{updated_count} 개의 상품이 비활성화되었습니다.")

    @ModelAdminBase.short_des("활성상태 토글")
    def make_inactive_inline(self, request, obj, parent_obj=None):
        if obj.is_active:
            obj.is_active = False
        else:
            obj.is_active = True
        msg = "비활성화" if obj.is_active else "활성화"
        obj.save()
        return messages.success(request, f"{obj.name} 상품이 {msg} 되었습니다.")

  • 훨씬 보기편해지고 훨씬 생산성과 (장고 원칙 - DIY) 좋은 협업의 통일점을 찾았다.

  • 비단 django admin에서 뿐만 아니다. 이런 형태의 class 상속 패턴, 템플릿 메서드 패턴(Template Method Pattern)은 django 의 철학을 담고있기 때문에 model, view, admin, drf 등 전반적인 부분에서 모두 나타난다.

3) 살펴볼만한 써드 파티

  • 이제 Django Debug ToolbarDRF 는 써드 파티라고 부르기도 어색할만큼 django와 한몸이다. 그래서 그 2가지는 제외했다.

(1) dj-database-url

  • 의외로 django 진영에서 인기가 높은 라이브러리다. 가볍고 목적이 명백해서 그런것 같다. 깃허브 레포가기

  • django의 Database 설정을 간략하게 할수있게 도와준다. 사용한 결과는 아래와 같다.

# settings.py
import dj_database_url

DATABASES = {
    'default': dj_database_url.config(default='sqlite:///:memory:')
}

(2) whitenoise

  • whitenoise 는 django 뿐 아니라 원래 목적은 python 웹어플리케이션에서 정적 파일 (static file)을 효과적으로 제공하기 위함이다. 깃허브 레포가기

  • django에서는 임시 production testing을 위해 DEBUG = False 로 세팅했을 때 기존에 static 파일로 쓰던 것들이 모두 경로를 못찾게 된다.

  • WhiteNoise는 static파일들을 collectstatics 명령수행시 지정경로에 파일들을 모아주는 역할을 하기 때문에 해당 방법을 예방할수 있다.

# settings.py
MIDDLEWARE = [
    ...
    'whitenoise.middleware.WhiteNoiseMiddleware',
    ...
]

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
  • 개발 환경에서 별도의 웹 서버 설정 없이 정적 파일을 제공하고자 할 때, 간단한 프로덕션 환경에서 웹 서버의 부하를 줄이고자 할 때, Heroku와 같은 플랫폼에서 정적 파일을 빠르고 효율적으로 제공하고자 할 때 사용하면 좋고 당연하게 production에서는 웬만하면 CDN을 사용하자!

(3) django-cors-headers

  • web application의 필요악인 CORS설정을 쉽고 빠르게 해주는 라이브러리다.
# settings.py
INSTALLED_APPS = [
    ...
    'corsheaders',
    ...
]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',
    ...
]

CORS_ORIGIN_ALLOW_ALL = False
CORS_ORIGIN_WHITELIST = [
    'http://example.com',
    'https://example.com',
]

(4) django-filter

  • Django에서 쿼리셋을 필터링할때 쉽고 빠르게 해주는 확장 라이브러리다. 깃허브 레포가기 실제 사용 예시는 깃허브 레포로 대체한다.

  • 허나 최적화나 성능에 있어서는 꼭 고려하고 제대로 사용할 필요가 있다!

(5) djoser & djangorestframework_simplejwt & social-auth-app-django & allauth


출처

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

4개의 댓글

comment-user-thumbnail
2023년 9월 22일

👍🏻

1개의 답글
comment-user-thumbnail
2024년 3월 29일

이야, admin 잘 쓴다고 생각했는데 몰랐던게 몇 개 있네요.

2: filter_horizontal, djangoql, @admin.display(ordering='is_active', description='Is Active'), custom template으로 모델 통계 추가하기
3: In-line Action, Base Admin Class

gif로 만들어서 한눈에 과정을 볼 수 있어서 좋네요. 고생 좀 하셨겠네요.
잘 배우고 갑니다.

1개의 답글