Published on

Django admin 에서 Custom Action Page 만들기

Authors
django-admin-custom-action-page_2.webp

고객은 단순히 쿠폰 하나씩 발행하는 시스템만 요청했지만, 하나씩 발행 하게 되면 사용성이 너무 떨어질 것 같다고 생각했습니다.

공수시간이 부족했지만 프로젝트의 완성도를 위해, 한 번에 여러 명의 사용자에게 쿠폰을 발행할 수 있는 기능을 추가했습니다.

결과적으로 없었으면 너무 귀찮았을그리고 만족할 수 있는 기능이 되었습니다.

이번 글에서는 Django Admin에서 여러 사용자에게 쿠폰을 일괄 발행하는 커스텀 액션 페이지를 만드는 과정을 기록합니다.

1. change_list Page Custom

django-admin-custom-action-page_1.webp

쿠폰(Coupon) 이라는 모델의 Admin 페이지 안에 액션을 만들기 위해서 Admin 클래스의 change_list 를 지정합니다.

이 때 템플릿의 경로는 some_app/templates/change_list.html 일때, 아래처럼 chainge_list_template을 지정합니다.


@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
    ..(생략)..

    change_list_template = "change_list.html"

Django Admin의 기본 change_list 템플릿을 상속받습니다.


{% extends "admin/change_list.html" %}
{% block object-tools-items %}
    {{ block.super }}
    <li>
        <a href="{% url 'admin:bulk_coupon' %}" class="button">쿠폰 일괄 발행</a>
    </li>
{% endblock %}

  • Django Admin 페이지의 오른쪽 상단에 있는 도구 모음을 의미합니다. 이 블록은 관리자에게 특정 작업을 수행할 수 있는 버튼을 추가할 수 있는 영역입니다.
  • 기본적으로 이 블록에는 '추가', '수정', '삭제' 등의 기능이 포함되는데, 여기서는 block.super로 기본 도구를 유지하면서 새로운 항목을 추가하고 있습니다.
  • {% url 'admin:bulk_coupon' %}: admin:bulk_coupon이라는 URL 네임스페이스를 사용하여 일괄 발행 페이지로 이동하는 링크를 생성합니다. 이 URL은 뒤에서 나올 CouponAdmin 클래스에서 추가한 bulk_coupon_view 뷰와 연결됩니다.

2. get_urls

어드민에서 get_urls 메서드를 오버라이드하여 URL을 추가합니다. (사실 이번 글에서 핵심이 되는 내용 입니다.)

get_urls 메서드는 Admin 클래스에서 URL 경로를 설정하여 관리 페이지에 새로운 기능을 추가하는 데 유용한 메서드 입니다.

@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
		...(생략)...

    def get_urls(self):
        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)

        check = [
            path("bulk_coupon/", wrap(self.bulk_coupon_view), name="bulk_coupon"),
            *super().get_urls(),
        ]
        return check

이를 통해 bulk_coupon_view라는 새로운 뷰를 관리자의 URL 패턴에 추가했습니다.

이 URL을 세팅함으로써 템플릿에 세팅해놓은 쿠폰을 일괄 발행하는 페이지로 이동하게 됩니다.

3. View

bulk_coupon_viewPOST 요청을 처리하고, 여러 사용자에게 한 번에 쿠폰을 발행하는 로직을 담당합니다.

뒤에서 만들 페이지에서 BulkCouponForm 렌더링하고, 폼에 따라 사용자로부터 데이터를 입력받습니다.

그렇게 입력 받은 데이터를 Coupon.objects.bulk_create() 메서드를 사용해 쿠폰을 일괄 발행합니다.


@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
		...(생략)...

    def bulk_coupon_view(self, request):
        form = BulkCouponForm(request.POST or None)
        if request.method == "POST" and form.is_valid():
            users = form.cleaned_data["users"]
            name = form.cleaned_data["name"]
            discount_type = form.cleaned_data["discount_type"]
            value = form.cleaned_data["value"]
            description = form.cleaned_data["description"]
            valid_from = form.cleaned_data.get("valid_from", None)
            valid_to = form.cleaned_data.get("valid_to", None)

            try:
                Coupon.objects.bulk_create(
                    [
                        Coupon(
                            user_id=user,
                            name=name,
                            discount_type=discount_type,
                            value=value,
                            description=description,
                            valid_from=valid_from,
                            valid_to=valid_to,
                        )
                        for user in users
                    ]
                )
                messages.success(request, f"{len(users)}에게 쿠폰이 성공적으로 발행되었습니다.")
            except Exception as e:
                messages.error(request, f"쿠폰 발행에 실패했습니다.{e}")
            return redirect("admin:index")
        return render(request, "bulk_coupon.html", {"form": form})

만약 로직이 실패하는 경우 어드민에서 알럿을 발행하기 위해, 쿠폰 발행로직 성공 여부에 따라 Django의 messages 모듈을 사용해 관리자에게 결과를 알립니다.

  • BulkCouponForm(request.POST or None)POST 데이터가 있으면 그 데이터를 사용해 폼을 생성하고, 없으면 빈 폼을 생성합니다.
  • render() 함수의 세 번째 인자로 템플릿에 전달할 데이터를 담은 딕셔너리(context)를 넘깁니다. 여기에서 {"form": form}으로 폼 객체가 전달됩니다.

4. Form

from django import forms

from app.coupon.models import CouponNameChoice, DiscountTypeChoice
from app.user.models import User

class BulkCouponForm(forms.Form):
    users = forms.MultipleChoiceField(label="쿠폰을 발행할 유저", widget=forms.CheckboxSelectMultiple)
    name = forms.ChoiceField(label="쿠폰 타입")
    discount_type = forms.ChoiceField(label="발행할 쿠폰 할인 유형", help_text="(정가는 정해진 금액이 할인, 정률은 %로 할인 됩니다.)")
    value = forms.IntegerField(
        label="할인 값", widget=forms.NumberInput(), help_text="(ex: 50   1.정가인 경우:50원 할인 2.정률인 경우:50 % 할인)"
    )
    description = forms.CharField(max_length=256, label="쿠폰 설명(256자 제한)", widget=forms.Textarea)
    valid_from = forms.DateField(
        label="유효 시작 기간",
        required=False,
        widget=forms.DateInput(attrs={"type": "date"}),
        help_text="(날짜를 선택하지 않으면 무기한 쿠폰입니다.)",
    )
    valid_to = forms.DateField(label="유효 종료 기간", required=False, widget=forms.DateInput(attrs={"type": "date"}))

    def __init__(self, *args, **kwargs):
        super(BulkCouponForm, self).__init__(*args, **kwargs)
        self.fields["users"].choices = [
            (user.pk, f"이름 : {user.name} / 닉네임 : {user.nick_name}") for user in User.objects.all()
        ]
        self.fields["name"].choices = CouponNameChoice.choices
        self.fields["discount_type"].choices = DiscountTypeChoice.choices

    def clean(self):
        cleaned_data = super().clean()
        if cleaned_data.get("value", 0) < 0:
            self.add_error("value", "0 이상의 값을 입력하세요.")
        if cleaned_data.get("discount_type") == DiscountTypeChoice.PERCENTAGE and cleaned_data.get("value", 0) > 100:
            self.add_error("value", "100이하의 퍼센트 값을 입력하세요.")
        if (cleaned_data.get("valid_from") and cleaned_data.get("valid_to")) and (
            cleaned_data.get("valid_from") > cleaned_data.get("valid_to")
        ):
            self.add_error("valid_to", "유효 종료 시간이 유효 시작 시간 보다 늦도록 입력하세요.")
        return cleaned_data

django-admin-custom-action-page_2.webp

4.1 Form Render

마지막으로, 관리자가 쿠폰을 일괄 발행할 수 있는 페이지의 UI를 정의하는 템플릿을 작성합니다.

자바스크립트로 전체 선택(select-all 부분) 체크박스 를 구현해 관리자의 편의성을 극대화했습니다!

{% extends "admin/base_site.html" %}
{% block content %}
<style>
    /* 스타일은 생략 */
</style>

<script>
    document.addEventListener('DOMContentLoaded', function () {
        document.getElementById('select-all').addEventListener('change', function () {
            var isChecked = this.checked;
            document.querySelectorAll('input[name="users"]').forEach(function (checkbox) {
                checkbox.checked = isChecked;
            });
        });
    });
</script>

<div class="admin-form-container">
    <div class="admin-form">
        <h1>쿠폰 일괄 발행</h1>
        <form method="post">
            {% csrf_token %}
            <div>
                <label><input type="checkbox" id="select-all"> 전체 선택</label>
            </div>
            {{ form.as_p }}

            <button type="submit" class="custom-submit-button">발행하기</button>
            <button type="button" style="background-color: #888; margin-top: 25px;" onclick="history.back();">뒤로 가기</button>
        </form>
    </div>
</div>
{% endblock %}

4.2 form.as_p

  • 이 부분이 BulkCouponForm 폼을 HTML로 렌더링하는 부분입니다.
  • form.as_p는 Django의 폼을 HTML <p> 태그로 묶어 자동으로 렌더링해주는 Django 의 헬퍼 메서드입니다. (각 필드가 <p> 태그로 감싸져 화면에 표시됩니다.)
  • users, name, discount_type, value, description, valid_from, valid_to 같은 필드들이 이 부분에서 자동으로 렌더링됩니다.

고객이 직접 요청하지 않았지만, 바쁜 와중에도 더 나은 서비스 제공을 위한 고민이 반영된 사례입니다.

Django Admin을 커스터마이징 하는 작업을 몇번 경험했기 때문에 역으로 제안할 수 있었던 기능이었던 것 같습니다.

5. 레퍼런스 링크

hongreat 블로그의 글을 봐주셔서 감사합니다!