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

antoliny0919
2024912
이전 포스트는 Django에서 제공하는 UserAdmin을 통해 ModelAdmin List페이지와 Change페이지에서 보여지는 부분을 커스터마이징 하는 방법에 대해서 알아봤다.
이번 포스트에서도 UserAdmin이 기본 ModelAdmin과 다르게 커스터마이징 한 부분을 통해 나중에 ModelAdmin을 커스터마이징 해야하는 상황과 마주했때 어떻게 해결해야할지 알아보자.
 
UserAdmin의 Add페이지
 
아래 이미지는 기본 ModelAdmin이 적용된 Post모델의 Add페이지와 Change페이지이다.
no-img
Add페이지와 Change페이지를 보면 모델 객체를 생성하거나 변경할때 사용되는 Form의 Field가 동일하다.
이번에는 ModelAdmin을 커스터마이징한 UserAdmin이 적용된 User 모델의 Add페이지와 Change페이지를 확인해보자.
no-img
필자의 버전
필자가 포스트를 작성할때 테스트한 버전은 5.1.0버전으로 UserAdmin Resetpassword와 관련된 변경사항이 있는 릴리즈이다. 그렇기때문에 필자의 UserAdmin페이지와 독자분들의 UserAdmin페이지에 약간의 차이가 존재할 수 있다.
User모델의 Add페이지와 Change페이지는 기본 ModelAdmin이 적용된 Post와는 다르게 Add Page에서 사용하는 Form과 Change Page에서 사용하는 Form이 다른걸 확인할 수 있다.
이번 포스트에서 가장 먼저 알아볼건 UserAdmin은 어떻게 Add페이지를 커스터마이징 했는가? 이다.
 

Add Page == Change Page ?

 
아래 소스코드는 각각 ModelAdmin에서 Change페이지와 Add페이지에 접속했을때 호출되는 함수들이다.
python
COPY

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

    def change_view(self, request, object_id, form_url="", extra_context=None):
        return self.changeform_view(request, object_id, form_url, extra_context)
 add_view, change_view의 반환문을 보면 똑같은 함수를 호출하는걸 확인할 수 있다.
한 가지 차이점으로 볼 수 있는 부분은 change_view의 경우에는 object_id인자를 추가로 전달하는데 Change같은 경우는 특정 객체를 대상으로 하는 작업이기 때문에 당연한 부분이다.
UserAdmin은 이 object_id인자를 통해 Add페이지와 Change페이지를 구분했다.
 

UserAdmin의 Add페이지를 위한 fieldsets

 
이전 포스트에서 필자가 fieldsets이라는 변수에 대해서 설명했었다.
Django ModelAdmin은 fieldsets변수를 통해 Add페이지나 Change페이지에 보여질 필드들이나 필드들을 분류해서 타이틀과 같은 라벨을 만들 수 있다.
그리고 해당 fieldsets은 ModelAdmin(정확히는 BaseAdmin)의 get_fieldsets메서드를 통해 가져오게 된다.
python
COPY

class ModelAdmin(BaseModelAdmin):
    def _changeform_view(self, request, object_id, form_url, extra_context):
        ...
        add = object_id is None

        if add:
            if not self.has_add_permission(request):
                raise PermissionDenied
            obj = None
        ...
        fieldsets = self.get_fieldsets(request, obj)
        ...

class UserAdmin(admin.ModelAdmin):
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "usable_password", "password1", "password2"),
            },
        ),
    )
    ...
    def get_fieldsets(self, request, obj=None):
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)
    ...
먼저 ModelAdmin의 _changeform_view코드를 보면 object_id가 존재하지 않으면 add변수는 True가 된다.
여기서 object_id가 존재하지 않는 상황은 바로 이전 코드에서 봤듯이 add_view가 호출되었을 때를 의미하며
즉 Django Admin페이지에서 ModelAdmin Add페이지에 접근했을때이다.
이후 조건문 if add가 True이기 때문에 obj변수에 None이 할당되고 해당 값이 get_fieldsets메서드를 호출할때 전달되는걸 확인할 수 있다.
여기서 UserAdmin은 위 get_fieldsets을 오버라이딩했기 때문에 UserAdmin의 get_fieldsets이 호출되고 만약 obj값이 None이라면 if not obj조건문에 의해 UserAdmin에 정의된 fieldsets변수가 아닌 self.add_fieldsets가 사용되게 된다.
간단하게 설명하면 Add페이지 같은 경우는 fieldsets변수값을 사용하지 않고 add_fieldsets값을 사용하겠다는 뜻이다.
no-img
그렇기 때문에 User모델의 ModelAdmin Add페이지가 add_fieldsets변수 값을 통해 위와 같은 형태가 될 수 있는 것이다.
필자가 예시로 add_fieldsets값에 is_active필드를 추가해보면
python
COPY

class UserAdmin(admin.ModelAdmin):
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "usable_password", "password1", "password2", "is_active"),
            },
        ),
    )
no-img
위와 같이 is_active필드가 Add페이지에 추가된걸 확인할 수 있다.
만약 UserAdmin에 get_fieldsets메서드를 오버라이딩 하지 않았다고 가정해보자.
그러면 Add페이지나 Change페이지 둘 다 fieldsets변수값에 의존하기 때문에(정확히 말하면 ModelAdmin의 get_fieldsets의 반환값) Add페이지나 Change페이지에 보여지는 필드들이 동일할거라고 예상할 수 있다.
이러한 이유가 Post모델의 ModelAdmin Add페이지 Change페이지가 동일한 필드들을 가진 이유이다.
python
COPY

class UserAdmin(admin.ModelAdmin):
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "usable_password", "password1", "password2"),
            },
        ),
    )
다시 UserAdmin의 add_fieldsets변수값을 보면
"usable_password", "password1", "password2"같은 경우는 User모델에 존재하지 않는 Field로 Form에 적용하려면 사용되는 FormClass에 해당 필드들을 따로 만들어줘야한다.
 

ModelAdmin의 ModelForm만들기

 
UserAdmin이 사용한 Form에 대해 알기전에 ModelAdmin이 ModelForm을 만드는 과정에 대해 간단히 알아보자.
python
COPY

class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
    ...
    form = forms.ModelForm

class ModelAdmin(BaseModelAdmin):
    def _changeform_view(self, request, object_id, form_url, extra_context):
        ...
        ModelForm = self.get_form(
            request, obj, change=not add, fields=flatten_fieldsets(fieldsets)
        )
        ...

    def get_form(self, request, obj=None, change=False, **kwargs):
        ...
        form = type(self.form.__name__, (self.form,), new_attrs)
        ...
        defaults = {
            "form": form,
            "fields": fields,
            "exclude": exclude,
            "formfield_callback": partial(self.formfield_for_dbfield, request=request),
            **kwargs,
        }
        ...
        try:
            return modelform_factory(self.model, **defaults)
        ...
ModelAdmin의 _changeform_view메서드를 보면 get_form메서드를 통해 ModelForm클래스를 생성하는 과정을 거친다.
그리고 get_form메서드에서 form변수에 값을 할당하는 부분을 보면 type메타클래스를 통해 클래스를 생성할때 인자로 self.form이 전달되는걸 확인할 수 있다.
여기서 self.form은 ModelAdmin에 따로 오버라이딩 하지 않았다면 BaseModelAdmin의 ModelForm클래스를 사용하게 된다.
ModelForm에서 사용되는 Fields를 정의할때 ModelForm내부에 Meta클래스를 생성하고 해당 Meta클래스 fields변수에 사용될 fields를 정의하면된다.
이러한 과정은 get_form메서드의 마지막 부분인 반환문 modelform_factory함수에서 자동으로 이루어지는데
modelform_factory함수를 보기전에 알아야할점은 modelform_factory함수에 인자로 전달된 값중 defaults라는 값에 fields(get_fieldsets())가 전달되었다는 부분이다.
python
COPY

def modelform_factory(
    model,
    form=ModelForm,
    fields=None,
    exclude=None,
    formfield_callback=None,
    widgets=None,
    localized_fields=None,
    labels=None,
    help_texts=None,
    error_messages=None,
    field_classes=None,
):
    attrs = {"model": model}
    if fields is not None:
        attrs["fields"] = fields
    if exclude is not None:
        attrs["exclude"] = exclude
    if widgets is not None:
        attrs["widgets"] = widgets
    if localized_fields is not None:
        attrs["localized_fields"] = localized_fields
    if labels is not None:
        attrs["labels"] = labels
    if help_texts is not None:
        attrs["help_texts"] = help_texts
    if error_messages is not None:
        attrs["error_messages"] = error_messages
    if field_classes is not None:
        attrs["field_classes"] = field_classes

    # If parent form class already has an inner Meta, the Meta we're
    # creating needs to inherit from the parent's inner meta.
    bases = (form.Meta,) if hasattr(form, "Meta") else ()
    Meta = type("Meta", bases, attrs)
    if formfield_callback:
        Meta.formfield_callback = staticmethod(formfield_callback)
    # Give this new form class a reasonable name.
    class_name = model.__name__ + "Form"

    # Class attributes for the new form class.
    form_class_attrs = {"Meta": Meta}

    if getattr(Meta, "fields", None) is None and getattr(Meta, "exclude", None) is None:
        raise ImproperlyConfigured(
            "Calling modelform_factory without defining 'fields' or "
            "'exclude' explicitly is prohibited."
        )

    # Instantiate type(form) in order to use the same metaclass as form.
    return type(form)(class_name, (form,), form_class_attrs)
modelform_factory함수는 위와 같이 조금 복잡한 편이지만 메타클래스를 잘 활용한 아름다운 함수이다.
필자가 간단하게 설명하면 modelform_factory함수는 전달된 form에서 추가적인 부분을 만들어주는 함수라고 볼 수 있다.
그중에서 가장 중요한 부분은 ModelForm내부에 사용될 Meta클래스를 위 modelform_factory에서 만든다는 것이다.
가장 처음부분을 보면 attrs라는 딕셔너리값에 인자로 전달해준 여러 값들을 추가하는 걸 확인할 수 있다.
여기서 이전에 필자가 중요하다고 언급했던 fields도 포함된다.
그리고 type메타클래스를 통해 Meta클래스를 생성할때 마지막 인수로 attrs를 전달했기 때문에
생성되는 Meta클래스는 "fields"값으로 get_fieldsets()의 반환값이 사용되는 것이다.
이후 마지막 반환문에서 이전에 생성한 ModelForm과 Meta가 담긴 form_class_attrs를 마지막 인수로 전달하여
아래와 같이 일반적으로 사용되는 ModelForm클래스 형태를 메타클래스를 통해 만들었다고 볼 수 있다.
python
COPY

# class_name = model.__name__ + "Form"
class YourModelNameForm(forms.ModelForm):
    ...
    class Meta():
        ...
        fields = get_fieldsets() 
위 코드를 이해하기 위해서는 Python 메타클래스에 대해서 알아야하지만 모를 수도 있는 독자분들을 위해 간단하게 설명하면
DjangoAdmin의 get_form메서드는 의미 그대로 fieldsets과 같이 커스터마이징에 사용되는 값을 통해 모델에 맞는 ModelForm 클래스를 생성해주는 과정이라고 보면 된다.
 

UserAdmin ModelForm

 
위와 같은 동작을 통해서 UserAdmin의 Form클래스를 생성한다고 가정하면
UserAdmin의 ModelForm Meta클래스 fields변수값에는 add_fieldsets("username", "usable_password", "password1", "password2")값이 할당되게 된다.
하지만 위에서도 설명했듯이 "username"은 문제가 없지만 "usable_password", "password1", "password2"같은 경우는 존재하지 않는 필드이기 때문에 문제를 일으키게 된다.
그렇기 때문에 UserAdmin의 Form에는 위 필드들을 추가하는 과정이 필요하고 UserAdmin은 Add페이지에 사용할 Form을 따로 정의하여 사용하는 방식으로 해결했다.
python
COPY

class UserAdmin(admin.ModelAdmin):
    add_form = UserCreationForm
    ...

    def get_form(self, request, obj=None, **kwargs):
        """
        Use special form during user creation
        """
        defaults = {}
        if obj is None:
            defaults["form"] = self.add_form
        defaults.update(kwargs)
        return super().get_form(request, obj, **defaults)
    ...

class UserCreationForm(BaseUserCreationForm):
    ...

class BaseUserCreationForm(SetPasswordMixin, forms.ModelForm):
    password1, password2 = SetPasswordMixin.create_password_fields()
    usable_password = SetPasswordMixin.create_usable_password_field()
    ...
UserAdmin의 get_form메서드를 보면 이전에 봤던 get_fieldsets과 같이 obj파라미터 값에 따라 Add인지 Change인지 구별한다.
Add의 경우 obj는 get_form메서드를 호출하는 _changeform_view메서드에 의해 전달되며 이전에 설명했던 get_fieldsets과 동일한 부분이다.
defaults딕셔너리 "form"키로 self.add_form인 따로 정의한 FormClass UserCreationForm을 할당한다.
그리고 AdminForm클래스의 get_form메서드를 호출할때 defaults인자를 전달하는데
python
COPY

class ModelAdmin(BaseModelAdmin):
    def get_form(self, request, obj=None, change=False, **kwargs):
        ...
        defaults = {
            "form": form,
            "fields": fields,
            "exclude": exclude,
            "formfield_callback": partial(self.formfield_for_dbfield, request=request),
            **kwargs,
        }
        ...
AdminForm클래스 get_form메서드 입장에서 kwargs값으로 "form": self.add_form이 담겨있어
defaults딕셔너리를 만들때 **kwargs를 통해 "form"값이 오버라이딩 되는 형식으로 self.add_form이 사용되게 된다.
지금까지 봤던 add_fieldsets, add_form변수와 get_fieldsets, get_form 메서드를 통해 UserAdmin은 자신만의 Add페이지를 만들 수 있었다.
추가로 Add페이지를 커스터마이징한 부분이 한 가지 더 있는데 add_form_template이라는 변수로 Add페이지에 적용되는 템플릿을 따로 설정할 수 있는 역할을한다.
python
COPY

class UserAdmin(admin.ModelAdmin):
    add_form_template = "admin/auth/user/add_form.html"

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
        request.current_app = self.admin_site.name

        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,
        )
ModelAdmin의 render_change_form메서드는 이전에 봤던 _changeform_view메서드에 의해 호출되는 메서드로 최종적으로 템플릿을 반환하는 메서드이다.
템플릿을 반환하기 전에 Add페이지이며 따로 템플릿을 설정했을 경우(if add and self.add_form_template is not None) 해당 템플릿을 선택하는 조건문을 확인할 수 있다.
그렇기 때문에 UserAdmin은 Add페이지의 Template또한 커스터마이징했다고 볼 수 있다.
 
UserAdmin의 비밀번호를 변경하는 추가 페이지
 
UserAdmin은 ModelAdmin이 제공하는 경로외에도 한 가지 경로를 더 추가했다.
no-img
UserAdmin모델의 Change페이지를 보면 Reset password라는 버튼이 보인다.
참고로 Reset password버튼은 Django 5.1버전에서 새로 추가된 부분이다.
no-img
위와 같이 5.1.0버전 미만이라면 해당 버튼이 존재하지 않아 당황할 수 있지만
password를 변경하는 페이지는 존재하기 때문에 문제없다.
5.1.0버전 이상이라면 해당 버튼을 눌렀을때 Password Change페이지로 이동하게 된다.
(이하버전은 url을 통해 이동하면 된다. --> Ex: /admin/auth/user/<YourUserObjectId>/password/)
no-img
이렇게 UserAdmin은 ModelAdmin이 제공하는 기본적인 페이지외에도 비밀번호를 변경할 수 있는 추가 페이지를 구현했다.
어떻게 구현했는지 UserAdmin의 코드를 통해 알아보겠다.
 

페이지 경로 추가하기

 
이전 포스트에서 필자가 잠깐 ModelAdmin의 get_urls메서드에 대해 설명한 부분이있는데
get_urls메서드는 ModelAdmin이 제공하는 url들을 path함수를 통해 추가하는 과정이 구현되어 있다.
UserAdmin은 ModelAdmin이 기본적으로 추가하는 url외에 추가적으로 비밀번호를 바꿀 수 있는 url(<id>/password/)을 get_urls메서드 오버라이딩을 통해 구현했다.
python
COPY

class UserAdmin(admin.ModelAdmin):
    ...
    def get_urls(self):
        return [
            path(
                "<id>/password/",
                self.admin_site.admin_view(self.user_change_password),
                name="auth_user_password_change",
            ),
        ] + super().get_urls()
    ...
 위 코드를 통해 admin/auth/user/{UserObjectId}/password/경로로 접근했을때 특정 유저(UserObjectId)의 password를 변경하는 로직 user_change_password 메서드가 호출된다.
위와 같이 ModelAdmin에서 기본으로 제공되는 로직 외에 추가 로직이 필요하면 get_urls메서드를 통해 경로를 지정하고
해당경로에 일치했을때 호출될 로직 또한 작성해야한다.
python
COPY

class UserAdmin(admin.ModelAdmin):
    ...
    @sensitive_post_parameters_m
    def user_change_password(self, request, id, form_url=""):
        user = self.get_object(request, unquote(id))
        if not self.has_change_permission(request, user):
            raise PermissionDenied
        if user is None:
            raise Http404(
                _("%(name)s object with primary key %(key)r does not exist.")
                % {
                    "name": self.opts.verbose_name,
                    "key": escape(id),
                }
            )
        ...

        return TemplateResponse(
            request,
            self.change_user_password_template
            or "admin/auth/user/change_password.html",
            context,
        )
    ...
user_change_password메서드를 확인해보면 가장 먼저 변경할 객체부터 가져온다.(self.get_object(..))
그리고 해당 유저에게 has_change_permission메서드를 통해 변경 권한이 존재하는지 확인하는데 여기서 has_change_permission과 같은 메서드는 AdminModel의 부모 클래스인 BaseModel에 정의되어있으며 has_change_permission외에도 add, delete, view 등등 여러 권한과 관련된 메서드가 정의되어있다.
참고로 위와 같이 권한을 검증할땐 Django에서 기본적으로 제공하는 PermissionMixin을 통해 특정 앱, 모델별로 디테일한 권한 검사가 가능하다.
(Django에서 제공하는 User모델은 PermissionMixin을 상속받는다.)
 

Permission

 
이번 포스트와 딱히 관련없는 부분이라고 생각하기 때문에 소스코드를 통해 설명하지는 않고 간단한 예시를 통해 설명하겠다.
위 user_change_password메서드에서 수행하는 권한검사인 has_change_permission을 통과하려면 접근한 유저에게 auth앱 User모델 Change권한이 필요하다.
필자의 antoliny0000유저에게 해당 권한이 존재할때와 존재하지 않을때를 비교해보자.
no-img
위와 같이 권한이 존재하지 않으면 403페이지를 응답받는다.
이렇게 Django는 PermissionMixin을 통해 앱, 모델별로 편리하게 권한을 설정할 수 있도록 한다.
Admin페이지같이 권한이 굉장히 중요한 역할을 하는곳에는 권한검사 로직은 필수이다.
python
COPY

class UserAdmin(admin.ModelAdmin):
if request.method == "POST":
    def user_change_password(self, request, id, form_url=""):
        ...
        if request.method == "POST":
            form = self.change_password_form(user, request.POST)
            if form.is_valid():
                # If disabling password-based authentication was requested
                # (via the form field `usable_password`), the submit action
                # must be "unset-password". This check is most relevant when
                # the admin user has two submit buttons available (for example
                # when Javascript is disabled).
                valid_submission = (
                    form.cleaned_data["set_usable_password"]
                    or "unset-password" in request.POST
                )
                if not valid_submission:
                    msg = gettext("Conflicting form data submitted. Please try again.")
                    messages.error(request, msg)
                    return HttpResponseRedirect(request.get_full_path())

                user = form.save()
                change_message = self.construct_change_message(request, form, None)
                self.log_change(request, user, change_message)
                if user.has_usable_password():
                    msg = gettext("Password changed successfully.")
                else:
                    msg = gettext("Password-based authentication was disabled.")
                messages.success(request, msg)
                update_session_auth_hash(request, form.user)
                return HttpResponseRedirect(
                    reverse(
                        "%s:%s_%s_change"
                        % (
                            self.admin_site.name,
                            user._meta.app_label,
                            user._meta.model_name,
                        ),
                        args=(user.pk,),
                    )
                )
        else:
            form = self.change_password_form(user)
user_change_password메서드를 계속 보면 변경할 객체 가져오기와 권한 검사 이후 POST요청과 GET요청일때의 로직을 확인할 수 있는데
먼저 POST요청일 경우의 코드를 보면 비밀번호 변경이 유효할때(form.is_valid()) 변경(form.save())하고 update_session_auth_hash함수를 통해 기존 세션을 유지해서 로그아웃 되지 않도록 한다.
그리고 마지막으로 해당 유저모델 객체의 Change페이지로 다시 이동시킨다.
이번에는 GET요청일때의 코드를 보면
python
COPY

class UserAdmin(admin.ModelAdmin):
    change_user_password_template = None
    change_password_form = AdminPasswordChangeForm
    ...
    def user_change_password(self, request, id, form_url=""):
        ...
        if request.method == "POST":
            ...
        else:
            form = self.change_password_form(user)

        fieldsets = [(None, {"fields": list(form.base_fields)})]
        admin_form = admin.helpers.AdminForm(form, fieldsets, {})

        if user.has_usable_password():
            title = _("Change password: %s")
        else:
            title = _("Set password: %s")
        context = {
            "title": title % escape(user.get_username()),
            "adminForm": admin_form,
            "form_url": form_url,
            "form": form,
            "is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET),
            "is_popup_var": IS_POPUP_VAR,
            "add": True,
            "change": False,
            "has_delete_permission": False,
            "has_change_permission": True,
            "has_absolute_url": False,
            "opts": self.opts,
            "original": user,
            "save_as": False,
            "show_save": True,
            **self.admin_site.each_context(request),
        }

        request.current_app = self.admin_site.name

        return TemplateResponse(
            request,
            self.change_user_password_template
            or "admin/auth/user/change_password.html",
            context,
        )
self.change_password_form을 통해 Password Change페이지에서 봤던 필드들(usable_password(Choice), password1(Char), password2(Char))이 정의된 AdminPasswordChangeForm객체를 생성한다.
그 다음으로 fieldsets변수를 설정하고 AdminForm객체를 생성하는 과정을 거치는데 해당 과정은 이전 포스트의 fieldsets부분을 보면 이해하기 쉽다.
참고로 AdminForm객체를 생성하는 과정은 필수라고 볼 수 있다.
마지막으로 TemplateResponse를 보면 self.change_user_password_template값이 존재하면 해당 템플릿을 사용하고 존재하지 않을때는 Django에서 제공하는 change_password.html 템플릿을 사용한다.
UserAdmin의 self.change_user_password_template은 None인데 이러한 부분들은 사용자의 커스터마이징을 고려한 부분이라고 볼 수 있다.
 

마지막으로

 
이렇게 지금까지 Django가 제공하는 UserAdmin을 통해 ModelAdmin을 어떻게 커스터마이징 했는지에 대해 알아봤다.
참고로 ModelAdmin과 BaseAdmin 코드가 작성된 파일인 django/contrib/admin/options.py는 2500줄이 넘는다.
그 뜻은 필자가 설명한 부분 외에도 다른 많은 부분들이 존재하며 해당 메서드들을 이해하면 더 디테일하게 커스터마이징이 가능하다.
Django에서 제공하는 만큼 UserAdmin은 ModelAdmin 커스터마이징의 좋은 예라고 생각한다.
이제 독자분들이 작성한 Model의 Model Admin페이지를 커스터마이징 할 차례다.
UserAdmin과 ModelAdmin 소스코드만 있다면 두려울게 없다.
UserAdmin, ModelAdmin이 포함된 options.py그리고 Django가 제공하는 Admin공식문서를 통해 원하는 형태의 ModelAdmin페이지를 완성하기 바란다.