[ django admin을 다양하게 custom하는 것을 확인하고 기록하기, model 대상의 다양한 action 들과 list view의 look and feel, 써드파티까지 핵심 위주로 살펴보기 ]
바로 앞 게시글에 이어서 간단한 프로젝트로 django에서 admin custom을 하는 접근방법에 대해 알아보자.
Action
,In-line action
,Excel import & export
를 다룰 것이다.튜토리얼을 이어가려면 다음글로!!
🔥 동일 시리즈 내의 바로 앞글과 이어지는 글입니다. Django admin custom (2) - 장고도 UX 개선을 해야한다! custom list & detail view, custom field & filter 와 추가 라이브러리 글 먼저 확인 부탁드리겠습니다!
Django Admin에서 Action은 관리자 페이지의 목록 뷰(list view)에서 여러 객체에 대한 일괄 작업을 수행할 수 있게 해주는 기능이다. 예를 들어, 여러 사용자를 한 번에 활성화 또는 비활성화하거나, 여러 객체를 삭제하는 등의 작업을 수행할 수 있다. 특히
worker (celery 등)
와 함께 사용해 django admin의 확장성과 활용성을 한 차원 끌어올려 준다!
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를 좀 더 고급지게 만들 수 있고 무거운 작업도 충분히 가능하게 만들 수 있다.
체크해서 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.py
의 INSTALLED_APPS
에 inline_actions
을 추가하자!
참고로 django 4 이상을 사용하고 있다면 해당 라이브러리에서 ImportError: cannot import name 'ugettext_lazy' from 'django.utils.translation'
가 날 것이다. (23.09.21 기준) light한 라이브러리 이지만 사용자도 커뮤니티도 꽤 구성되어 있는데 2년 전부터 버전 관리가 안되어 있다..
그래서 직접 패키지 코드를 바꾸는게 편하다.. 패키지경로/inline_actions/admin.py
에서 import하는 부분만 바꾸면 된다. (아래 사진 참조)
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_actions
attribute에 바인딩되는 inline action method만 추가해주면 모든게 끝이다!"django-import-export"
이다. 다양한 데이터 포맷 (CSV, JSON, Excel 등)을 지원하며 Admin에서 데이터를 import/export 할 수 있게 해주는 라이브러리이다. 깃허브 레포 링크
pip install django-import-export
로 설치하고 settings.py
의 INSTALLED_APPS
에 import_export
를 추가한다. 기본 세팅은 완료! official docs 에서 다양한 설정값에 대해 확인할 수 있다.
# 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
resources.ModelResource
를 상속받는 class가 django ORM model 대상으로 "추출하고 싶은 field를 정리한 형태"로 구성한다.
그런 다음 지금까지 우리가 했던것과 같이 admin register class에서 ImportExportModelAdmin
를 상속받고 resource_class
attribute를 1에서 구현한 Resource class로 mapping 해주면 끝이다.
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 를 만들고 싶은 욕구가 생긴다.
custom search
에서 사용했던 DjangoQLSearchMixin
link_to_customer
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')]
와 같은 쌍으로 선언해야 한다. 실제 적용하는 부분을 한 번 보면 바로 와닿을 것이다.
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
)
# ...생략...
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 등 전반적인 부분에서 모두 나타난다.
Django Debug Toolbar
와 DRF
는 써드 파티라고 부르기도 어색할만큼 django와 한몸이다. 그래서 그 2가지는 제외했다. dj-database-url
의외로 django 진영에서 인기가 높은 라이브러리다. 가볍고 목적이 명백해서 그런것 같다. 깃허브 레포가기
django의 Database 설정을 간략하게 할수있게 도와준다. 사용한 결과는 아래와 같다.
# settings.py
import dj_database_url
DATABASES = {
'default': dj_database_url.config(default='sqlite:///:memory:')
}
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'
django-cors-headers
# settings.py
INSTALLED_APPS = [
...
'corsheaders',
...
]
MIDDLEWARE = [
...
'corsheaders.middleware.CorsMiddleware',
...
]
CORS_ORIGIN_ALLOW_ALL = False
CORS_ORIGIN_WHITELIST = [
'http://example.com',
'https://example.com',
]
django-filter
Django에서 쿼리셋을 필터링할때 쉽고 빠르게 해주는 확장 라이브러리다. 깃허브 레포가기 실제 사용 예시는 깃허브 레포로 대체한다.
허나 최적화나 성능에 있어서는 꼭 고려하고 제대로 사용할 필요가 있다!
djoser & djangorestframework_simplejwt & social-auth-app-django & allauth
django는 user관리가 어렵다기 보단 "귀찮다". 거의 일관된 modeling을 가지는 user model과 특히 jwt token을 주로 사용하는 restAPI 라면 더 "귀찮다"
그런 일련의 과정과 더욱이 OAuth까지 챙겨주는 django 진영의 기특한 효자 라이브러리들이다. 내용은 너무 방대하기 때문에 아래 official docs로 대체한다.
하지만 꼭 직접 User를 만들어 본 뒤, 어답터패던을 쓰는 이유와 추상 User model, full custom User model 등을 경험한뒤 쓰는 것을 추천한다.
👍🏻