Django UserAdmin을 통해 ModelAdmin페이지 커스터마이징 준비하기 - [0]

antoliny0919
202492
Django는 깔끔한 디자인에 편리하게 사용할 수 있는 Admin페이지를 제공한다.
사용자는 여러 모델을 생성하고 admin.py에 admin.site.register(모델이름)와 같은 코드 한 줄만 입력했을뿐인데
Admin페이지에서는 해당 모델을 파악하고 관리할 수 있는 ModelAdmin페이지를 자동으로 생성한다.
가끔은 자동으로 생성해주는 ModelAdmin페이지의 구성이 맘에 안들때가 있다.
위와 같은 상황에서는 어떻게 해야할까?
python
COPY

from django.contrib import admin

class MyModelAdmin(admin.ModelAdmin):
    ...

admin.site.register(MyModel, MyModelAdmin)
답은 간단하다 따로 ModelAdmin이라는 클래스를 통해 해당 ModelAdmin 페이지를 커스터마이징 하면 된다.
하지만 말이 쉽지 어디서부터 건들어야 하고 어떻게 커스터마이징을 해야한단 말인가?
Django에서는 기본적으로 User모델을 제공하고 User모델의 ModelAdmin또한 따로 제공한다.
즉 ModelAdmin을 커스터마이징 한 좋은 예가 있다는 뜻이기도 하다.
이번 포스트는 Django가 제공하는 UserAdmin클래스를 통해 ModelAdmin을 어떻게 커스터마이징 해야하고 특정 변수들이 어떠한 로직을 통해 ModelAdmin페이지를 만드는지에 대해 알아보겠다.
 
좋은 예시, UserAdmin
 
일단 가장 먼저 필자가 테스트 하는 환경에 대해 먼저 설명하겠다.
필자는 따로 User모델을 커스터마이징 하지 않았다.
Django가 제공해주는 User와 UserAdmin을 그대로 사용한 상태에서 테스트를 진행하겠다.
 
만약 Django 프로젝트를 생성하고 User모델을 커스텀하지 않았다면 Django가 제공하는 User모델과 UserAdmin이 @admin.register데코레이터를 통해 자동으로 적용되어 있을것이다.
python
COPY

@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    add_form_template = "admin/auth/user/add_form.html"
    change_user_password_template = None
    fieldsets = (
        (None, {"fields": ("username", "password")}),
        (_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                ),
            },
        ),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "usable_password", "password1", "password2"),
            },
        ),
    )
    form = UserChangeForm
    add_form = UserCreationForm
    change_password_form = AdminPasswordChangeForm
    list_display = ("username", "email", "first_name", "last_name", "is_staff")
    list_filter = ("is_staff", "is_superuser", "is_active", "groups")
    search_fields = ("username", "first_name", "last_name", "email")
    ordering = ("username",)
    filter_horizontal = (
        "groups",
        "user_permissions",
    )
    ...
 일단 코드에 대해 알아보기전에 해당 ModelAdmin이 적용된 User모델 Admin페이지에 접속해보겠다. 
 

List 페이지

 
no-img
필자의 User모델 Admin페이지를 보면 몇 명의 유저가 존재하는지 확인할 수 있으며 유저의 몇가지 필드를 통해 간단한 정보도 파악할 수 있다.
그리고 상단에는 특정 필드값으로 검색할 수 있는 기능과 오른쪽에는 Filter기능이 존재하며 Staff, Superuser, Active 상태값에 따라 보여지는 유저들을 Filtering할 수 있는 기능이라는걸 예상할 수 있다.
반면에 필자가 생성한 Post모델의 Admin 페이지를 확인해 보면
no-img
User모델에 비해 상당히 초라하단걸 느낄 수 있다.
Filter, Search 기능도 없을 뿐더러 Post의 여러 필드가 보여지는게 아닌 Post모델의 매직메서드 __str__의 값만 확인할 수 있다.
python
COPY

from django.db import models
from django.contrib.auth.models import User

# Create your models here.

class Post(models.Model):
  
  title = models.CharField(max_length=256)
  content = models.TextField()
  author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts", to_field="username")
  created_at = models.DateField(auto_created=True)
  
  def __str__(self):
    return f"{self.title}-{self.author}"
이렇게 커스텀된 ModelAdmin이 적용된 User와 기본 형태인 Post의 ModelAdmin페이지 차이는 굉장히 크다.
UserAdmin페이지가 이와 같은 차이를 낼 수 있는 이유는 아래 네가지 변수에 의해서다.
python
COPY

@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    ...
    list_display = ("username", "email", "first_name", "last_name", "is_staff")
    list_filter = ("is_staff", "is_superuser", "is_active", "groups")
    search_fields = ("username", "first_name", "last_name", "email")
    ordering = ("username",)
    ...
각 변수들이 Admin List페이지에서 어떤 역할을 하는지 소개하겠다.
1. list_display: Admin List페이지에서 보여질 Model의 필드들
2. list_filter: User Admin페이지 오른쪽에 있던 특정 필드를 통해 Filtering을 할 수 있는 기능
3. search_fields: User Admin페이지 상단에 있던 특정 필드의 필드값을 통해 모델 객체를 검색할 수 있는 기능
4. ordering: Admin List페이지에서 보여질 Model객체들의 정렬 방식을 특정 필드를 통해 표현
no-img
위와 같은 네 가지 필드들을 PostAdmin에도 추가하면 UserAdmin과 비슷한 형태의 페이지가 될것이다.
python
COPY

from django.contrib import admin
from .models import Post

# Register your models here.

class PostAdmin(admin.ModelAdmin):
  list_display = ("title", "author", "created_at")
  list_filter = ("created_at",)
  ordering = ("-id",)
  search_fields = ("title",)

admin.site.register(Post, PostAdmin)
이렇게 필자의 PostAdmin페이지에 list_display필드로 "title", "author", "created_at"을 설정했고 ordering으로 "-id"필드를 설정했기 때문에 최근에 생성한 Post들이 상단에 위치하는 "id"값이 내림차순 형태로 Post들이 정렬되어 보여질 것이다.
그리고 search_fields와 list_filter변수도 설정했기 때문에 UserAdmin페이지와 같이 search, filter할 수 있는 태그들이 생성될 것이다.
실제로 확인해보면
no-img
필자의 설정들이 잘 적용된 걸 확인할 수 있다.
 
fieldsets의 역할과 적용되는 과정
 
이번에는 List페이지가 아닌 각 객체의 필드정보를 확인하고 수정할 수 있는 Change페이지에 대해서 알아보자
List페이지에 있는 객체들을 클릭해보면 해당 객체의 정보를 확인할 수 있으며 수정할 수 있는 페이지인 Change 페이지로 이동하게 된다.
no-img
위 사진은 필자의 환경에서 User antoliny0919객체의 정보가 담긴 페이지로 조금 짤려있는 형태이지만 Django에서 제공하는 UserAdmin을 그대로 사용하면 위와 같은 형태가 된다.
Reset password
아마 독자분들의 환경에서 Password필드에 있는 Reset password버튼이 없을 수도 있다.이 버튼은 작성일 기준 Django의 최신 공식버전인 5.1버전에서 추가된 버튼으로 5.1이상 버전이 아니라면 Rest password버튼 외에는 전부 다 동일할 것이다.
이번에도 기본형태인 필자의 Post모델 Change페이지와 비교하며 어떠한 점이 다른지 확인해보겠다.
 

fieldset 분류

 
no-img
Post모델의 Change페이지를 확인해보면 Post모델의 모든 필드들의 타입과 알맞은 형태의 태그들이 전부 있으며 선택된 객체의 필드값을 확인할 수 있고 수정도 할 수 있다.
언뜻보면 커스텀된 UserAdmin과 별 다른 차이가 없어보이지만 겉으로 보기에 딱 하나의 차이점이 존재한다.
no-img
UserAdmin에는 위와 같이 몇개의 필드들을 묶어서 분류하듯이 Personal Info와 같은 텍스트가 담긴 블럭들이 존재한다.
python
COPY

@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    ...
    fieldsets = (
        (None, {"fields": ("username", "password")}),
        (_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                ),
            },
        ),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
    )
    ...
UserAdmin클래스의 fieldsets변수값을 보면 조금 복잡한 형태이지만 이전에 봤던 Personal info라는 텍스트를 확인할 수 있으며 같은 튜플의 값으로 딕셔너리형태에 필드들이 담겨있는걸 확인할 수 있다.
위 코드만 봐도 fieldsets이라는 변수가 어떤 역할을 하는지 예상할 수 있다.
바로 Change페이지에서 보여질 필드들을 설정할 수 있고 분류해서 각 분류마다 이름도 지정할 수 있다는 것이다.
python
COPY

from django.contrib import admin
from .models import Post

# Register your models here.

class PostAdmin(admin.ModelAdmin):
  list_display = ("title", "author", "created_at")
  list_filter = ("created_at",)
  ordering = ("-id",)
  search_fields = ("title",)
  
  fieldsets = (
    (("Main"), {"fields": ("title", "content")}),
    (("Sub"), {"fields": ("author", "created_at")}),
  )

admin.site.register(Post, PostAdmin)
필자의 Post모델에도 fieldsets이라는 변수에 Main, Sub라는 타이틀과 각 분류에 맞는 필드들을 설정해 보았다.
no-img
위와 같이 fieldsets의 값이 Post모델 Change페이지에 잘 적용된 걸 확인할 수 있다.
지금까지 Post ModelAdmin을 커스터마이징 해보면서 사실 별 특별한 코드 없이 특정 변수에 알맞은 규칙으로 값을 할당하기만 하면 사용자가 원하는 형태의 구성으로 ModelAdmin페이지를 만들 수 있었다.
어떻게 가능한걸까?
fieldsets이 적용되는 과정에 대해서 알아보자.
 

ModelAdmin이 제공하는 기본 urls

 
ModelAdmin클래스가 get_urls메서드를 통해 기본적으로 추가하는 경로들에 대해 먼저 알아본다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    ...
    def get_urls(self):
        from django.urls import path

        def wrap(view):
            def wrapper(*args, **kwargs):
                return self.admin_site.admin_view(view)(*args, **kwargs)

            wrapper.model_admin = self
            return update_wrapper(wrapper, view)

        info = self.opts.app_label, self.opts.model_name

        return [
            path("", wrap(self.changelist_view), name="%s_%s_changelist" % info),
            path("add/", wrap(self.add_view), name="%s_%s_add" % info),
            path(
                "<path:object_id>/history/",
                wrap(self.history_view),
                name="%s_%s_history" % info,
            ),
            path(
                "<path:object_id>/delete/",
                wrap(self.delete_view),
                name="%s_%s_delete" % info,
            ),
            path(
                "<path:object_id>/change/",
                wrap(self.change_view),
                name="%s_%s_change" % info,
            ),
            # For backwards compatibility (was the change url before 1.9)
            path(
                "<path:object_id>/",
                wrap(
                    RedirectView.as_view(
                        pattern_name="%s:%s_%s_change"
                        % ((self.admin_site.name,) + info)
                    )
                ),
            ),
        ]
        ...
get_urls메서드의 반환값을 보면 path함수를 통해 여러 경로를 추가하는걸 확인할 수 있다.
가장 먼저 추가되는 ""경로가 바로 이전에 봤던 List페이지에 해당된다.
그리고 나머지 "add", "<path:object_id>/history/", "<path:object_id>/delete/", "<path:object_id>/change/", "<path:object_id>/" 등등 ModelAdmin클래스의 get_urls메서드는 각 ModelAdmin의 기본경로를 추가하는 동작을 한다.
이전에 필자가 fieldsets변수를 소개할 때 ModelAdmin에서 선택된 객체의 Change페이지에 접속했는데 해당 페이지에 접속했을때의 경로가 바로 "<path:object_id>/change/"이다.
get_urls메서드에서 "<path:object_id>/change/"경로를 추가했을때의 path인자들을 보면
self.change_view를 인자로주어 해당경로에 접근했을때 호출되는 로직이 담긴 View를 연결했다.
즉 "<path:object_id>/change/"와 같은 change페이지에 접근했을때 self.change_view메서드가 호출된다는 뜻이다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    ...
    def change_view(self, request, object_id, form_url="", extra_context=None):
        return self.changeform_view(request, object_id, form_url, extra_context)

    @csrf_protect_m
    def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
        with transaction.atomic(using=router.db_for_write(self.model)):
            return self._changeform_view(request, object_id, form_url, extra_context)

    def _changeform_view(self, request, object_id, form_url, extra_context):
        ...
    ...
self.change_view메서드는 changeform_view메서드를 호출하고 changeform_view메서드는 _changeform_view를 호출한다.
위 과정은 본래의 목적인 fieldsets과 관련있는 동작을 수행하지 않기 때문에 넘어가겠다.
fieldsets과 관련된 실질적인 동작을 하는 부분은 _changeform_view메서드인데 _changeform_view메서드는 의미 그대로 Change페이지 템플릿에 전달된 데이터를 수집하고 특정 메서드로 요청이 왔을때의 처리 동작이 담겨있는 메서드이다.
150줄가까이 되므로 fieldsets과 관련된 부분만 첨부하고 짚어가겠다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    ...
    def _changeform_view(self, request, object_id, form_url, extra_context):
        ...
        fieldsets = self.get_fieldsets(request, obj)
        if request.method == "POST":
            ...
        ...
        admin_form = helpers.AdminForm(
            form,
            list(fieldsets),
            # Clear prepopulated fields on a view-only form to avoid a crash.
            (
                self.get_prepopulated_fields(request, obj)
                if add or self.has_change_permission(request, obj)
                else {}
            ),
            readonly_fields,
            model_admin=self,
        )
        ...
        context = {
            **self.admin_site.each_context(request),
            "title": title % self.opts.verbose_name,
            "subtitle": str(obj) if obj else None,
            "adminform": admin_form,
            "object_id": object_id,
            "original": obj,
            "is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET,
            "to_field": to_field,
            "media": media,
            "inline_admin_formsets": inline_formsets,
            "errors": helpers.AdminErrorList(form, formsets),
            "preserved_filters": self.get_preserved_filters(request),
        }
        ...
        return self.render_change_form(
            request, context, add=add, change=not add, obj=obj, form_url=form_url
        )
_changeform_view메서드에서 get_fieldsets메서드를 통해 설정된 fieldsets변수값을 가져오거나 fieldsets변수에 아무런 값이 할당되어 있지 않을때 특정 모델에 알맞는 기본값을 만들어내는 동작을 수행한다.
필자의 Post ModelAdmin같은 경우는 self.fieldsets값이 존재한다.(필자의 PostAdmin에 설정된)
python
COPY

# posts/admin.py
class PostAdmin(admin.ModelAdmin):
  ...
  fieldsets = (
    (("Main"), {"fields": ("title", "content")}),
    (("Sub"), {"fields": ("author", "created_at")}),
  )

# django/contrib/admin/options.py
class ModelAdmin(BaseModelAdmin):
    ...
    def get_fieldsets(self, request, obj=None):
        """
        Hook for specifying fieldsets.
        """
        if self.fieldsets:
            return self.fieldsets
        return [(None, {"fields": self.get_fields(request, obj)})]
그렇기 때문에 첫 번째 조건문이 True가 되어 필자가 설정한 self.fieldsets값이 그대로 반환된다.
반환된 fieldsets값은 AdminForm클래스 객체를 생성하는데 전달하게 된다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    ...
    def _changeform_view(self, request, object_id, form_url, extra_context):
        ...
        fieldsets = self.get_fieldsets(request, obj)
        if request.method == "POST":
            ...
        ...
        admin_form = helpers.AdminForm(
            form,
            list(fieldsets),
            # Clear prepopulated fields on a view-only form to avoid a crash.
            (
                self.get_prepopulated_fields(request, obj)
                if add or self.has_change_permission(request, obj)
                else {}
            ),
            readonly_fields,
            model_admin=self,
        )
        ...
        context = {
            **self.admin_site.each_context(request),
            "title": title % self.opts.verbose_name,
            "subtitle": str(obj) if obj else None,
            "adminform": admin_form,
            "object_id": object_id,
            "original": obj,
            "is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET,
            "to_field": to_field,
            "media": media,
            "inline_admin_formsets": inline_formsets,
            "errors": helpers.AdminErrorList(form, formsets),
            "preserved_filters": self.get_preserved_filters(request),
        }
        ...
        return self.render_change_form(
            request, context, add=add, change=not add, obj=obj, form_url=form_url
        )
여기서 AdminForm클래스 객체에 대해 알기전에 일단 fieldsets값을 통해 AdminForm객체를 생성했고 해당 객체가 context변수에 "adminform"키 값에 담겨 _changeform_view메서드의 반환값인 self.render_change_form에 context인자로 전달된다는것만 알고있자.
그 다음으로 호출되는 render_change_form메서드가 실질적인 Template을 반환하는 메서드로 Template에 전달할context인자에 특정 데이터를 채우는 역할을 한다.
하지만 중요한 건 필자의 목표인 fieldsets은 render_change_form메서드를 거쳐갈 뿐 딱히 fieldsets과 관련된 작업은 하지 않는다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    ...
    def render_change_form(
        self, request, context, add=False, change=False, form_url="", obj=None
    ):

        if add and self.add_form_template is not None:
            form_template = self.add_form_template
        else:
            form_template = self.change_form_template
        ...
        return TemplateResponse(
            request,
            form_template
            or [
                "admin/%s/%s/change_form.html" % (app_label, self.opts.model_name),
                "admin/%s/change_form.html" % app_label,
                "admin/change_form.html",
            ],
            context,
        )
render_change_form메서드의 코드를 보면 가장 먼저 조건문으로 add요청이면서 self.add_form_template이 존재하면 add_form_template을 적용하는데 필자는 add가 아닌 change를 요청한 상황이기 때문에 add가 False로 조건문에 해당되지 않는다.
그 다음은 반환문 TemplateResponse로 필자의 상황에서는 form_template이 None이기 때문에
1. "admin/app_label("posts")/self.opts.model_name("post")/change_form.html"
2. "admin/app_label("posts")/change_form.html"
3. "admin/change_form.html"
위와 같은 순서대로 템플릿을 찾게 된다.
이렇게 동작하는 이유는 각 모델마다 커스텀한 템플릿을 사용할 수 있도록 하기 위해서다.
필자는 Post모델과 관련하여 커스텀한 템플릿이 없기 때문에 Django가 제공하는 기본 템플릿인 "admin/change_form.html"을 사용하게 되고 인자로 전달한 context에는 "adminform"이라는 키에 fieldsets을 통해 생성된 AdminForm클래스 객체가 담겨있다는걸 기억하고 있자.
이제 "admin/change_form.html"템플릿을 보면서 "adminform"이라는 키가 어디서 사용되었는지 확인하면 된다.
 

fieldset 적용

 
html
COPY


{% extends "admin/base_site.html" %}
...
{% block field_sets %}
{% for fieldset in adminform %}
  {% include "admin/includes/fieldset.html" with heading_level=2 id_suffix=forloop.counter0 %}
{% endfor %}
{% endblock %}
...
"change_form.html"템플릿에서 field_sets과 관련된 블럭을 찾을 수 있다.
해당 부분을 보면 for 반복문으로 adminform객체를 반복하는데 여기서 필자와 독자분들은 "adminform"이 어떤 값을 가지고 있는지 알고 있다.
바로 fieldsets을 통해 생성된 AdminForm클래스 객체이다.
즉 AdminForm클래스 객체를 반복한다는 의미로 AdminForm클래스가 반복되기 위해서는 Iterable한 타입이여야 하기 때문에 __iter____getitem__매직 메서드가 구현되어 있을 거라는걸 예상할 수 있다.
실제로 AdminForm 클래스에 위와 같은 매직 메서드가 구현되어 있는지 확인해보자.
python
COPY

class AdminForm:
    ...
    def __iter__(self):
        for name, options in self.fieldsets:
            yield Fieldset(
                self.form,
                name,
                readonly_fields=self.readonly_fields,
                model_admin=self.model_admin,
                **options,
            )
AdminForm클래스 코드를 보면 예상대로 __iter__매직 메서드가 구현되어있는걸 확인할 수 있다.
그 뜻은 위 템플릿에서 봤던 {% for fieldset in adminform %}이 부분에서 AdminForm클래스의 __iter__매직 메서드가 동작하게 된다.
여기서 __iter__매직 메서드 코드를 보면 Fieldset클래스 객체가 생성되고
다시 템플릿 태그로 돌아와서 반복할때 마다 {% include %} --> include템플릿 태그를 사용한다.
즉 반복할 때 마다 include템플릿이 가져오는 "admin/includes/fieldset.html"템플릿이 적용되며 __iter__매직 메서드로 생성된 Fieldset클래스 객체가 해당 템플릿에 전달된다
no-img
html
COPY

{% comment %} fieldset.html {% endcomment %}

...
    {% if name %}
        {% if fieldset.is_collapsible %}<details><summary>{% endif %}
        <h{{ heading_level|default:2 }} id="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading" class="fieldset-heading">{{ fieldset.name }}</h{{ heading_level|default:2 }}>
        {% if fieldset.is_collapsible %}</summary>{% endif %}
    {% endif %}
...
"fieldset.html"템플릿에서 사용되는 fieldset은 "change_form.html"에서 adminform을 반복한 값인 Fieldset클래스 객체가 된다.
위 템플릿 태그를 보면 Fieldset객체에 name속성의 여부에 따라 특정 태그가 적용되는데
필자의 환경에서 Fieldset객체가 어떤 인자를 받아 생성되는지 알아야 해당 태그가 적용되는지 안되는지 알 수 있다.
python
COPY

class AdminForm:
    ...
    def __iter__(self):
        for name, options in self.fieldsets:
            yield Fieldset(
                self.form,
                name,
                readonly_fields=self.readonly_fields,
                model_admin=self.model_admin,
                **options,
            )

class Fieldset:
    def __init__(
        self,
        form,
        name=None,
        readonly_fields=(),
        fields=(),
        classes=(),
        description=None,
        model_admin=None,
    ):
        self.form = form
        self.name, self.fields = name, fields
        self.classes = " ".join(classes)
        self.description = description
        self.model_admin = model_admin
        self.readonly_fields = readonly_fields

    @cached_property
    def is_collapsible(self):
        if any([field in self.fields for field in self.form.errors]):
            return False
        return "collapse" in self.classes
가장 먼저 self.fieldsets의 값은 이전에도 설명했듯이 필자의 PostAdmin모델에 설정된 fieldsets값이다.
그리고 해당 값을 반복하면 name에는 "Main", "Sub"분류할 필드들의 타이틀이 되고 options에는 각 분류에 해당하는 fields들이 담기게 된다. --> {"fields": ("title", "content")}, {"fields": ("author", "created_at")}
no-img
즉 필자의 환경에서는 FieldSet클래스 객체가 두 개 생성되며 각 FieldSet객체의 name어트리뷰트로 "Main"과 "Sub"가 할당된다.
그렇기 때문에 {% if name %} 템플릿 태그가 True가 되어 템플릿 태그가 적용된다.
html
COPY

{% comment %} fieldset.html {% endcomment %}

...
    {% if name %}
        {% if fieldset.is_collapsible %}<details><summary>{% endif %}
        <h{{ heading_level|default:2 }} id="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading" class="fieldset-heading">{{ fieldset.name }}</h{{ heading_level|default:2 }}>
        {% if fieldset.is_collapsible %}</summary>{% endif %}
    {% endif %}
...
위 템플릿 태그가 바로 필자의 Post ModelAdmin change페이지에서 봤던 분류할 필드들의 타이틀과 같은 태그가 된다.
no-img
이렇게 fieldsets변수값인 "Main", "Sub"부분이 템플릿에 적용되는 과정을 알아봤다.
다음은 각 Fieldset클래스 객체 fields어트리뷰트에 할당된 값들이 "fieldset.html"템플릿에서 어떻게 적용되는지에 대해 알아보자
html
COPY

...
    {% if name %}
        {% if fieldset.is_collapsible %}<details><summary>{% endif %}
        <h{{ heading_level|default:2 }} id="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading" class="fieldset-heading">{{ fieldset.name }}</h{{ heading_level|default:2 }}>
        {% if fieldset.is_collapsible %}</summary>{% endif %}
    {% endif %}
    ...
    {% for line in fieldset %}
        <div class="form-row{% if line.fields|length == 1 and line.errors %} errors{% endif %}{% if not line.has_visible_field %} hidden{% endif %}{% for field in line %}{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% endfor %}">
            {% if line.fields|length == 1 %}{{ line.errors }}{% else %}<div class="flex-container form-multiline">{% endif %}
            {% for field in line %}
                <div>
                    {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
                        <div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% endif %}{% if field.is_checkbox %} checkbox-row{% endif %}">
                            {% if field.is_checkbox %}
                                {{ field.field }}{{ field.label_tag }}
                            {% else %}
                                {{ field.label_tag }}
                                {% if field.is_readonly %}
                                    <div class="readonly">{{ field.contents }}</div>
                                {% else %}
                                    {{ field.field }}
                                {% endif %}
                            {% endif %}
                        </div>
                    {% if field.field.help_text %}
                        <div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
                            <div>{{ field.field.help_text|safe }}</div>
                        </div>
                    {% endif %}
                </div>
            {% endfor %}
            {% if not line.fields|length == 1 %}</div>{% endif %}
        </div>
    {% endfor %}
    ...
...
위 템플릿을 보면 fieldset값을 반복문에서 사용한다.
그 뜻은 fieldset클래스 또한 이전에 봤던 AdminForm클래스와 동일하게 __iter__매직 메서드가 구현되어 있고 반복문에서 해당 매직 메서드가 호출된다.
python
COPY


class Fieldset:
    ...
    def __iter__(self):
        for field in self.fields:
            yield Fieldline(
                self.form, field, self.readonly_fields, model_admin=self.model_admin
            )
Fieldset클래스의 __iter__매직 메서드를 보면 현재 필자의 PostAdmin같은 경우는 Fieldset클래스 객체가 총 두개 생성되었고 각 self.fields값은 아래와 같을 것이다.
첫 번째 Fieldset객체 --> self.name="Main", self.fields=("title", "content")
두 번째 Fieldset객체 --> self.name="Sub", self.fields=("author", "created_at")
Fieldset클래스의 __iter__매직 메서드는 위 self.fields값을 반복함으로써 각 필드값이 Fieldline클래스 객체 생성에 전달된다.
여기서 Fieldline클래스 객체는 총 4개가 생성될 것이고
"fieldset.html"템플릿에서 사용되는 line({% for line in fieldset %})이 위 생성된 각 Fieldline클래스 객체일 것이다.
html
COPY

...
            {% for field in line %}
                <div>
                    {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
                        <div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% endif %}{% if field.is_checkbox %} checkbox-row{% endif %}">
                            {% if field.is_checkbox %}
                                {{ field.field }}{{ field.label_tag }}
                            {% else %}
                                {{ field.label_tag }}
                                {% if field.is_readonly %}
                                    <div class="readonly">{{ field.contents }}</div>
                                {% else %}
                                    {{ field.field }}
                                {% endif %}
                            {% endif %}
                        </div>
                    {% if field.field.help_text %}
                        <div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
                            <div>{{ field.field.help_text|safe }}</div>
                        </div>
                    {% endif %}
                </div>
            {% endfor %}
...
위 템플릿 코드를 보면 알 수 있듯이 생성된 Fieldline클래스 객체도 반복문에 사용되어 이때까지 봤던 과정처럼 또 __iter__매직 메서드를 호출하게 될 것이다.
python
COPY

class Fieldline:
    def __init__(self, form, field, readonly_fields=None, model_admin=None):
       ...

    def __iter__(self):
        for i, field in enumerate(self.fields):
            if field in self.readonly_fields:
                yield AdminReadonlyField(
                    self.form, field, is_first=(i == 0), model_admin=self.model_admin
                )
            else:
                yield AdminField(self.form, field, is_first=(i == 0))
결국 템플릿에 최종적으로 사용되는 field는 위 __iter__매직 메서드에서 볼 수 있는 일반적으로 AdminField클래스 객체이다.
이때까지 봐왔던 과정을 간단하게 정리하면 아래와 같은 구조라고 볼 수 있다.
(물론 ReadOnly속성이 적용되어 있으면 마지막 부분에서 예외가 있을 수 있다.)
no-img
템플릿을 보면 field객체에 field, label_tag, contents, field 등등 다양한 속성에 접근하는데 필자의 Post ModelAdmin같은 경우에 생성되는 AdminField객체에 위 속성들이 어떤 값을 가지고 있는지 알아보자.
python
COPY

class AdminField:
    def __init__(self, form, field, is_first):
        self.field = form[field]  # A django.forms.BoundField instance
        self.is_first = is_first  # Whether this field is first on the line
        self.is_checkbox = isinstance(self.field.field.widget, forms.CheckboxInput)
        self.is_readonly = False

    def label_tag(self):
        classes = []
        contents = conditional_escape(self.field.label)
        if self.is_checkbox:
            classes.append("vCheckboxLabel")

        if self.field.field.required:
            classes.append("required")
        if not self.is_first:
            classes.append("inline")
        attrs = {"class": " ".join(classes)} if classes else {}
        # checkboxes should not have a label suffix as the checkbox appears
        # to the left of the label.
        return self.field.label_tag(
            contents=mark_safe(contents),
            attrs=attrs,
            label_suffix="" if self.is_checkbox else None,
        )

    def errors(self):
        return mark_safe(self.field.errors.as_ul())
AdminField __init__메서드 파라미터 form에는 AdminForm으로부터 내려오는 PostForm타입의 객체와 self.field에 해당 Form에 전달된 field와 일치하는 부분만 가지는 BoundField타입의 객체를 가진다.
html
COPY

...
                            {% if field.is_checkbox %}
                                {{ field.field }}{{ field.label_tag }}
                            {% else %}
                                {{ field.label_tag }}
                                {% if field.is_readonly %}
                                    <div class="readonly">{{ field.contents }}</div>
                                {% else %}
                                    {{ field.field }}
                                {% endif %}
                            {% endif %}
...
위 템플릿을 보면 {{ field.field }}를 사용하는 부분이 있는데
AdminField().field로 각 field값에 맞는 BoundField객체가 사용된다.
즉 "title"같은 경우는 CharField로 만들어져 있기 때문에 CharField와 연결된 BoundField객체가 field어트리뷰트에 할당되어 있고 BoundField같은 경우는 RenderableFieldMixin클래스를 상속받았기 때문에 {{ field.field }}템플릿 태그 부분은 사실상 각 BoundField에 __str__매직 메서드를 사용한 효과와 같다.
여기서 RenderableFieldMixin은 __str__매직 메서드에 각 필드타입에 맞는 형식의 html태그를 반환하기 때문에
위 템플릿에서 {{ field.field }}의 반환값은 BoundField의 __str__매직 메서드 호출 반환값이라고 보면 된다.
no-img
마지막으로 {{ field.label_tag }}템플릿 태그에 대해서 설명하면 위 AdminField클래스의 label_tag메서드의 반환값이 해당 템플릿 태그부분이 되는데
반환값을 보면 self.field.label_tag()로 BoundField의 label_tag메서드 반환값이 사실상 AdminField label_tag메서드의 반환값이 된다.
여기서 BoundField label_tag메서드의 반환값에 대해서 알아보면
python
COPY

class BoundField(RenderableFieldMixin):
    ...
    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
        ...
        if id_:
            id_for_label = widget.id_for_label(id_)
            if id_for_label:
                attrs = {**(attrs or {}), "for": id_for_label}
            if self.field.required and hasattr(self.form, "required_css_class"):
                attrs = attrs or {}
                if "class" in attrs:
                    attrs["class"] += " " + self.form.required_css_class
                else:
                    attrs["class"] = self.form.required_css_class
        context = {
            "field": self,
            "label": contents,
            "attrs": attrs,
            "use_tag": bool(id_),
            "tag": tag or "label",
        }
        return self.form.render(self.form.template_name_label, context)
    ...
self.form.render메서드를 사용하여 {{ field.label_tag }}부분또한 따로 template("label.html")이 존재하고 context를 통해 해당 라벨의 실질적인 값인 FormField 어트리뷰트와 어울리는 데이터를 전달하여 렌더링하게 된다.
no-img
위와 같은 동작을 통해 Django Admin페이지 Label들을 보면 required한 필드같은 경우는 태그에 다른 클래스를 적용하여 찐하게 표시되어 있는데 이러한 부분들이 단 하나의 부분으로 만들어내는게 아닌 BoundField에서 템플릿에 전달할 필드의 특징을 담은 데이터를 만들어내고 RenderableFieldMixin이 해당 부분을 html 태그로 표현하며 Django Template이 context로부터 전달된 값들을 적용하는 방식을 통해 Admin페이지 전체도 아닌 한 컴포넌트를 만들어 낸다.
그렇기 때문에 사용자 입장에서 Admin클래스에 fieldsets변수값으로 튜플에 담긴 문자열만 전달했을뿐인데 Django는 위와 같이 복잡한 과정을 통해 각 Field의 특징과 속성에 최적화된 html태그들을 만들어 내는 것이다.
 
지금까지 필자가 Django에서 제공하는 UserAdmin클래스를 통해 Django는 어떻게 커스텀 ModelAdmin페이지를 만들었는지에 대해서 알아봤다.
사실 알아봤다 라고 하기에는 몇가지 다루지도 않았다.
아직 UserAdmin이 커스텀하지 않은 일반적인 Admin과 차이점을 내는 큰 부분이 한 가지 더 있다.
이 부분은 다음 포스트에서 이어서 다루도록 하겠다.