Programming/Python

[Django] Django Admin 리스트 페이지 사용하기

True or False 2023. 6. 12. 11:34

Django Admin 리스트 페이지 사용하기

기본적인 사용 방법

from model import Item
from django.contrib import admin

admin.site.register(Item)

위의 있는 코드는 정말 기본적인 코드이다.

 

그래도 저것만으로도 이미 많은 부분을 다룰 수 있게 해준다는 점에서 Django Admin은 매우 유용하다.

(ID 만 보고 Detail 페이지 들어가기, ID 만 보고 Row 삭제하기)

 

또, 손쉽게 기능을 추가하는 것도 가능하다. HTML 건드는 것 빼고 말이다. (필터, 정렬, 검색, 추가적인 액션)

 

여기에서 나는 내가 겪었던 문제들을 해결했을 때의 방법을 쓰려고 한다.

 

거기에는 간단한 것도 있었고 이정도로 할 정도면 그냥 Admin 을 프론트에서 만드는 것도??? 라고 생각했던 것들도 있다.

작업을 위한 작업

나는 현재 예시를 위해서 간단하게 건물 주인과 세입자 관리를 위한 Admin 리스트 페이지를 제공한다.

리스트 페이지는 각각의 필드를 보여주어야 하며 건물 리스트 페이지의 경우에는

현재 건물 주인과 현재 건물 세입자를 보여주어야 하며 건물 리스트 페이지에서 세입자의 계약 연장 혹은 계약 만료를 할 것이다.

각각의 페이지는 검색, 정렬을 제공해주어야 하며 건물 리스트 페이지만 위의 것과 추가해서 현재 세입자 계약에 따른 필터를 해주어야 한다. (최종 코드는 밑에 쓸 예정)

리스트 페이지에서 필드를 보여주기(기본)

리스트 페이지에서 ID 만 나온다면 그걸 보고 작업하는 사람은 얼마나 좋을까?

 

하나의 작업을 위해서 리스트 페이지에서 디테일 페이지로 본인이 원하는 Row를 찾을 때까지

반복하는 BruteForce 방식의 알고리즘을 사용하거나 특정 기준으로 정렬이 되어있어서 이분탐색을 사용하는 사람도 있을 수도 있다.

 

일단 만약에 저렇게 하는 사람이 있다면 대단하다. 하지만 리스트 페이지가 정확하게 유용하기 위해서는 ID 이외에도 많은 정보를 보여줘야 한다.

 

지금은 그것을 어떻게 표현하지는 확인해본다.

 

모델

# models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class Person(models.Model):
    name = models.CharField(max_length=50)
    age = models.IntegerField()

class House(models.Model):
    class Category(models.TextChoices):
        APARTMENT = 'Apartment', _('아파트')
        House = 'House', _("단독 주책")
        Studio = 'Studio', _('원룸')

    post_number = models.CharField(max_length=10)
    address = models.CharField(max_length=100)
    detail_address = models.CharField(max_length=100)
    category = models.CharField(choices=Category.choices, max_length=50)

class Ownership(models.Model):

    class Category(models.TextChoices):
        BUY = 'Buy', _('매매')
        LONG_TERM = 'LongTerm', _('전세')
        SHORT_TERM = 'ShortTerm', _('월세')

    owner = models.ForeignKey(Person, on_delete=models.DO_NOTHING, related_name='ownerships')
    house = models.ForeignKey(House, on_delete=models.DO_NOTHING, related_name='ownerships')
    category = models.CharField(choices=Category.choices, max_length=50)
    amount = models.IntegerField()
    started = models.DateField()
    ended = models.DateField(null=True, blank=True)

 

# admin.py
from django.contrib import admin
from .models import House, Ownership, Person

admin.site.register(House)
admin.site.register(Ownership)
admin.site.register(Person)

만약 처음에 설명한 기본적인 사용 방법을 사용하면 마주하는 페이지이다.

그리고 밑에는 그것을 개선한 페이지이다. 최소한으로 만들어야 할 페이지는 밑에 있는 페이지이다.

이제는 리스트만 보고도 정확히 무엇을 의미하는 지 알 수 있다.

힘들게 일일히 디테일 페이지를 들어가는 수고는 사라진 것이다.

코드는 아래처럼 변경되는데

# models.py
class Person(models.Model):
    # verbose_name 을 추가로 필드의 이름을 설명 일반 필드의 경우는 따로 명시할 필요가 없지만
    # ForeignKey의 경우에는 verbose_name 으로 명시가 필요하다.
    name = models.CharField(_('이름'), max_length=50) 
    age = models.IntegerField(_('나이'))

    class Meta:
        verbose_name = '사람' # 어드민에서 표시될 모델의 이름
        verbose_name_plural = '사람' # 어드민에서 표시될 복수형 모델의 이름

    def __str__(self):
        return f'{self.name}({self.age})' # 외래키로 연결된 모델에서 보여짐

# admin.py
class PersonAdmin(admin.ModelAdmin):
    list_display = ['id', 'name', 'age'] # 리스트를 보여 줄 항목들

일단 내가 말하는 최소한의 준비는 list_display ,verbose_name , __str__ 이다.

이정도만 해도 리스트 페이지 쓰는 것의 거부감을 안 느낀다.

 verbose_name 의 경우 코드에서도 설명했지만 추가적으로 하면 Model 에 있는 class Meta:Field 에 들어가는 것이 있다. 기본적으로 둘 다 설정을 해주고 Field 에서 유의할 것 이 외래키를 제외한 Field 의 경우에는 
첫번째 args 로 들어가면 문제는 없지만 ForeignKey 의 경우에는
verbose_name=_('계약 주체') 이렇게 명식적으로 해주어야한다.

 

심화

여태까지는 모델 필드에 있는 부분만 admin에 표시해주었지만 그것만으로는 부족할 수도 있다.

 

예를 들면 현재 빌딩의 주인은 누구이고 세입자가 있는지 알아야 할 수도 있다.

하지만 House 모델에서는 그것을 표현해주는 필드가 없다.

 

그래서 해당하는 것을 보여주기 위해서 몇가지 작업이 필요하다.

# admin.py

class HouseAdmin(admin.ModelAdmin):
    list_display = [
        'id', 'get_current_owner', 'get_current_tenant',
        'category', 'post_number', 'address', 'detail_address'
    ]

    @admin.display(description='현재 건물 주인')
    def get_current_owner(self, obj):
        return obj.ownerships.filter(
            category=Ownership.Category.BUY
        ).order_by('-started').first().owner.name

    @admin.display(description='현재 건물 세입자')
    def get_current_tenant(self, obj):
        tenant_ownership = obj.ownerships.filter(
            category__in=[
                Ownership.Category.LONG_TERM,
                Ownership.Category.SHORT_TERM
            ],ended__gt=timezone.now()
        ).order_by('-started').first()

        return tenant_ownership.owner.name if tenant_ownership else '없음'

만약에 완성한다면 아래와 같은 리스트 페이지를 볼 수 있을 것이다.

결과물은 합격이다. 현재 건물의 주인과 건물의 세입자가 나오니까 말이다.

 

하지만 코드는 아쉬운 부분이 많다. 지금은 문제가 없지만 문제의 소지가 될 경우도 있다.

 

예시로 만약에 집의 개수가 늘어나고 계약의 개수가 늘어날 경우에는 list의 개수만큼 작업이 3배 정도는 늘어날 것 같다.

 

그래서 해당 부분을 약간의 개선해서 아래와 같이 작성했다.

# models.py
class HouseQuerySet(models.QuerySet):
    def current_owner(self):
        ownership = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category=Ownership.Category.BUY
        ).order_by('-started').values('owner__name')
        return self.annotate(
            current_owner=ownership[:1]
        )

    def current_tenant(self):
        ownership = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category__in=[
                Ownership.Category.LONG_TERM, 
                Ownership.Category.SHORT_TERM
            ],
            ended__gt=timezone.now()
        ).order_by('-started').values('owner__name')
        
        return self.annotate(
            current_tenant=ownership[:1]
        )

class HouseManager(models.Manager):
    def get_queryset(self):
        return HouseQuerySet(self.model).current_tenant().current_owner()

class House(models.Model)
    ...
    objects = HouseManager()

# admin.py
class HouseAdmin(admin.ModelAdmin):
    list_display = [
        'id', 'get_current_owner', 'get_current_tenant',
        'category', 'post_number', 'address', 'detail_address'
    ]

    @admin.display(description='현재 건물 주인')
    def get_current_owner(self, obj):
        return obj.current_owner

    @admin.display(description='현재 건물 세입자')
    def get_current_tenant(self, obj):
        return obj.current_tenant

이렇게 변경함으로 얻어지는 이점이 무엇인가?

 

일단 HouseAdmin 에서는 기존 흐름에 추가가 되는 부분이 미미하다.

이유는 이전 작업에서 필요한 부분을 다 갖추어진 상태에서 시작하기 때문이다.

그리고 db에 날리는 쿼리도 하나로 줄어들었다.

💡 약간의 예시를 들자면 집에서 고기 구운거랑 캠핑장에서 고기 구운차이라고 하자.
집에서는 가스레인지 혹은 인덕션을 키고 고기를 굽는거고(DB 에서 처리)
캠핑장에서는 숯 가져와서 불 피우고 불이 어느정도 죽은 후에 고기를 굽는데 그것도 힘들다. (APP 에서 처리)

 

그래도 약간의 불편함은 있다 하지만 현재는 실질적인 서비스가 목적이 아닌 공부목적에 있다보니 넘어간다.

 

💡 다만 여기에서 아쉬운 것은 Django 가 시작 단계에서 Admin에서 Model 에 대한 체크를 하다보니
중간에 RunTime 에 들어가는 필드의 경우에는 아쉽게도 적용이 안된다.
만약에 방법이 있고 그것을 적용했으면 더욱 더 직관적으로 쓰고 흐름에 맞게 쓸 것 같은데 아쉽다.
내가 못 찾은 것일 수도 있어서 만약에 방법이 있다면 해당 부분을 적용하면 좋을 것 같다.
# admin.py
list_filter = ['current_owner', 'current_tenant'] # 이렇게 되면 좋겠다.

리스트 페이지에서 정렬

위의 부분을 해결하고 지금 이곳을 본다면 매우 편하게 적용이 가능하다.

# admin.py
class HouseAdmin(admin.ModelAdmin):
    list_display = [
        'id', 'get_current_owner', 'get_current_tenant',
        'category', 'post_number', 'address', 'detail_address'
    ]
    ordering = [
        'id', 'category', 'post_number', 'address', 'detail_address'
    ] # ordering 을 추가
		
    # ordering 을 명식적으로 추가
    @admin.display(ordering='current_owner', description='현재 건물 주인')
    def get_current_owner(self, obj):
        return obj.current_owner
	
    @admin.display(ordering='current_tenant', description='현재 건물 세입자')
    def get_current_tenant(self, obj):
        return obj.current_tenant

admin.ModelAdmin 의 속성 ordering 의 값을 넣어주면 동작을 한다.

 

admin.dispaly 의 경우 해당 필드에 접근이 가능하다면 ordering 이 가능한데 현재 필드상태에서는 불가능하다.

 

위의 말은 APP 단에서 처리하는 경우에는 정렬을 할 수가 없다는 것이다.

 

그렇기에 정렬을 위해서도 Annotate 를 통해서 필드를 만들어주는 것이 좋다.

 

💡 참고하면 재미있는 것은 admin 페이지에서 정렬을 사용할 경우
정렬되는 필드의 이름이 나오는 것이 아니고 필드의 순번이 나온다.

첫번째 필드가 id 인데 순번으로 1번이다.
그러면 우리가 생각했을 때에는 ?o=id 가 나올 것 같지만 ?o=1 로 나온다.
만약에 여기에서 정렬을 추가하면 필드는 category 순번이 5번째에 있으면 ?o=1.5 이렇게 나온다.

그래서 그런지 여기에서도 ordering list 에 current_owner, current_tenant 가 못 들어간다.
이유는 해당 순번에 관여가 list_display 에 의해 이루어지기 때문이다.

만약에 list_display 가 해결될 경우 간편해지는 것은 ordering 도 있다는 것이다.

 

리스트 페이지에서 검색

검색은 기대해도 좋다. 너무 편하다.

심지어 annotate 로 생성된 필드도 그냥 사용할 수 있다. 난 이부분이 너무 맘에든다.

class HouseAdmin(admin.ModelAdmin):
    ...
    search_fields = [
        'category', 'post_number', 'address', 'detail_address', 
        'current_owner', 'current_tenant'
    ]
    ...

위에 있는 search_fields 를 보면 알겠지만 그냥 필드의 이름만 적어주면 된다.

심지어 annotate 된 필드도 사용이 가능하다. 너무 좋다.

 

💡 annotate 가 된다는 것을 이용해서 본인이 새롭게 필드를 만들어서 검색 기능에 넣는 것도 가능하다.
하지만 필요할 때만 하는 것을 추천한다.
annotate 도 결국에는 자원의 소모가 발생하기 때문이다.
많아지고 복잡해지면 그럴 수록 시스템의 안 좋은 영향을 끼치게 된다.

리스트 페이지에서의 필터 추가

필터는 약간 애매하기는 하다.

있으면 좋기는 하지만 없어도 딱히 문제는 못 느낀다.

하지만 필요하다면 만들어줘야 한다.

그냥 평범하게 만들어 버리는 경우는

list_filter=['원하는 필드명']

이렇게만 해줘도 해결이 되지만

 

현재 원하는 경우는 필터가 현재 세입자에 관한 것이다 보니 이것하고는 약간 경우가 다르다.

현재 세입자가 없는 경우, 전세, 월세 이렇게 나뉘어지다 보니 해당하는 부분에 대응을 위해서 따로 커스텀하게 만들어준다.

# models.py
class HouseQuerySet(models.QuerySet):
    ...
    def current_status(self):
        categories = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category__in=[Ownership.Category.LONG_TERM, Ownership.Category.SHORT_TERM],
            ended__gt=timezone.now()
        ).order_by('-started').values('category')

        return self.annotate(
            ownership_category=Case(
                When(condition=Exists(categories[:1]), then=categories[:1]),
                default=Value('empty')
            )
        )

# admin.py
class HouseAdminFilter(admin.SimpleListFilter):
    title = '계약'

    parameter_name = 'ownership_category' # url 에 들어갈 파라미터

    def lookups(self, request, model_admin): # 파라미터에 들어갈 값
        return (
            ('empty', '세입자 없음'), # (실제값, 리스트 페이지에서 보이는 텍스트)
            (Ownership.Category.LONG_TERM, '전세'),
            (Ownership.Category.SHORT_TERM, '월세'),
        )

    def queryset(self, request, queryset):
        if self.value(): # value 가 있는 경우에만 동작 만약에 세입자가 없다고 None으로 해버리면 동작을 안함
            return queryset.filter(ownership_category=self.value())


class HouseAdmin(admin.ModelAdmin):
    ...
    list_filter=[HouseAdminFilter]
    @admin.display(ordering='ownership_category', description='현재 건물 계약상태')
    def get_current_ownership_category(self, obj):
        categories = {
            'empty': '현재 세입자 없음',
            'LongTerm': '전세',
            'ShortTerm': '월세',
        } # 현재 category value 가 영어이다보니 mapping 을 통한 수동 번역
        return categories.get(obj.ownership_category)

일단 이것을 만들기 위해서 한 것은 annotate를 통해서 ownership_category 를 추가해주었다.

해당 row 가 존재하면 그 계약의 category 를 사용하고 없는 경우에는 default 로 empty 로 설정했다.

 

그 이후에는 먼저 @admin.display 로 필드를 보여주고 정상작동 확인 후 커스텀 필터인

HouseAdminFilter 를 통해서 필터를 걸어줬는데

 

중요한 것은 lookups 에 부분에서 값을 넘긴다고 생각하면 되기에 실제로 필드에 있는 값과 맞춰주기만 하면된다.

그것만 하면 동작은 잘 될 것이다.

리스트 페이지에서 액션 추가

admin 에서 액션은 매우 유용하다. 앞으로 소개할 두개의 액션 처리기법 보다는 간편하기 때문이다.

# 첫번째 방식
@admin.action(description='세입자 계약 만료')
def expire_ownership(modeladmin, request, queryset):
    for q in queryset:
        q.ownerships.filter(
            category__in=[
                Ownership.Category.LONG_TERM,
                Ownership.Category.SHORT_TERM
            ]
        ).update(ended=timezone.now())

class HouseAdmin(admin.ModelAdmin):
    ...
    actions = ['expire_ownership']
    ...

# 두번째 방식
class HouseAdmin(admin.ModelAdmin):
    ...
    actions = ['expire_ownership']
    ...

    @admin.action(description='세입자 계약 만료')
    def expire_ownership(self, request, queryset):
        for q in queryset:
            q.ownerships.filter(
                category__in=[
                    Ownership.Category.LONG_TERM,
                    Ownership.Category.SHORT_TERM
                ]
            ).update(ended=timezone.now())

이렇게 약간의 코드만 추가하면 단일이든 여러개이든 한꺼번에 처리할 수 있는 액션이 생성된다.(선택한 경우에만)

어느방식을 취하든 본인의 맘이다. 우선순위는 클래스 내부에 있는 것이 먼저이다.

 

개인적으로는 재사용성이 가능하면 첫번째 방식을 사용하고 불가능한 경우에는 재사용성 있게 나누어서

클래스 내부에서 받아서 외부 함수로 처리한다.

💡 from django.contrib import messages messages 이 친구를 이용해서 성공여부 등을 표시하는 것도 가능하다. messages.add_message(request, messages.INFO, '성공', extra_tags='세입자 만료')

 

리스트 페이지의 필드에 버튼 생성

여기서부터는 사실 약간 귀찮은 부분이 있다.

일전에 사용한 admin.display 를 사용해서 버튼을 만드는 것인데. 문제는 html을 써야하다보니 데이터의 흐름만 다루다보면 별로 달갑지는 않은 작업이다.

하지만 해야 한다 그것이 일이니까 말이다. 본인이 좋아하는 것만 할 수는 없다.

class HouseAdmin(admin.ModelAdmin):
    list_display = [... , 'get_extend_one_year_button']
    ...
		
    @admin.display(description='현재 세입자 1년 연장하기')
    def get_extend_one_year_button(self, obj):
        return format_html(
            '<a class="button">1년 연장</a>'
        )

짜잔! 버튼이 추가된다.

하지만 저 버튼은 아직 동작 하지 않는다.

버튼이 동작되는 순간은 버튼이 눌렸을 때이다.

 

그래서 결국 저 버튼을 누르면 admin 에서 request 를 받아 동작을 처리하는 방식으로 진행한다.

class HouseAdmin(admin.ModelAdmin):
    ...
		
    # get_urls 를 오버라이드를 하고 우리가 request를 받아야 할 url을 추가해준다.
    # 중요한 것은 name을 통해서 reverse를 할 예정이기에 name을 지정해준다.
    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path(
                '<int:object_id>/extend-one-year', 
                self.admin_site.admin_view(self.extend_one_year), # 사용할 함수
                name='extend-one-year'
			),
        ]
        final_url = urls + my_urls
        return final_url
		
		
    @admin.display(description='현재 세입자 1년 연장하기')
    def get_extend_one_year_button(self, obj):
        ownerships = obj.ownerships.filter(
            category__in=[
                Ownership.Category.LONG_TERM,
                Ownership.Category.SHORT_TERM
            ],
            ended__gt=timezone.now(),
        ).order_by('-started')

        if not ownerships:
            return format_html(
                '<span>현재 세입자가 존재하지 않습니다.</a>',
            )

        ownership_pk = ownerships.first().id
				# 일단 해당하는 html에 reverse 로 경로와 args에 동작할 ownership pk를 지정
        return format_html(
            '<a class="button" href="{}">1년 연장</a>',
            reverse('admin:extend-one-year', args=[ownership_pk])
        )
		
    # object_id 는 우리가 넘긴 ownership_pk 가 온다.
    # 작업이 마무리되면 HttpResponseRedirect 를 통해 동작했던 페이지로 redirect
    @staticmethod
    def extend_one_year(request, object_id):
        try:
            ownership = Ownership.objects.get(id=object_id)
            ownership.ended = ownership.ended + datetime.timedelta(days=365)
            ownership.save()
        except Ownership.DoesNotExist:
            raise ValidationError('Does not exist')
        return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/'))

어떻게 보면 views.pyurls.py, template.html 을 한 class 에 작성한 느낌이 든다.

그래서 그런지 이게 담당하는 역할이 많아질 수록 코드는 복잡해질 것이고 유지보수가 어려워진다.

그때에는 코드 개선이 필요하지만 지금은 어떻게 동작하는지에 초점을 맞추었기에 이렇게 한다.

💡 @admin.display 에서 에러가 나는 경우 해당 리스트 페이지가 렌더링이 안된다. 그러기에 예외에 대한 경우에는 ValidationError 보다는 따로 해당 케이스에 대한 문구를 띄워주는 방식이 적합하다.
위의 코드처럼 세입자가 없는 경우에는 버튼활성화가 되면 안되다보니 세입자 없다는 문구를 띄워주면 오류없이 사용이 가능하다.

 

통합 액션 만들기

여태까지의 액션은 체크박스를 통한 선택 혹은 단일에 관해서만 동작을 했다.

그런데 만약에 전체에 관한 작업을 해야할 때 에는 어떻게 해야할까?

페이지 사이즈를 무한정으로 늘려서 한 페이지에 모든 row가 나와서 체크박스를 선택해 작업을 해야할까?

아니다 해당 방식은 너무 큰 비효율이다.

 

만약에 데이터가 크다면 페이지에 뿌려야 할 데이터로 인해서 심각한 성능이슈가 발생할 것이다.

그러기에 그냥 admin에서 전체적으로 동작하는 액션을 제공을 할 필요가 있다.

그러기 위해서는 이전과는 다르게 change_list_template 를 수정할 필요가 있다.(제일 싫은 부분이다)

일단 만들 기능은 현재 세입자 계약을 계약 항목과 종료일자를 일자를 입력받아서 맞게 늘리는 것이다.

 

{% extends 'admin/change_list.html' %} /* */
<!-- 기존 change_list.html 을 extend를 한다. -->
{% block object-tools %}
		<!-- 우리는 모든 페이지에서 해당 부분이 보여야 하니 object-tools 안에 작업을 한다 -->
    <div>
        <form action="extend-ownership-by-days" method="POST">
            {% csrf_token %}
                <strong>항목</strong> :
                <input type="radio" id="categoryAll" name="category" value="all">
                <label for='categoryAll'>전체</label>
                <input type="radio" id="longTerm" name="category" value="longTerm">
                <label for='longTerm'>전세</label>
                <input type="radio" id="shortTerm" name="category" value="shortTerm">
                <label for='shortTerm'>월세</label>
                <br  />
                <br  />
                <strong>증가 일수</strong> :
                <input type="number" id='days' name="days">
                <br />
                <br />
                <button class="button" style="margin-bottom: 1rem" type="submit">일수 늘리기</button>
        </form>
    </div>
    {{ block.super }}
		<!-- object-tools에서 기본으로 동작하는 부분이 살아야하니 block.super  -->
{% endblock %}
<!-- endblock 으로 닫아준다.  -->

class HouseAdmin(model.ModelAdmin):
    ...
    change_list_template = 'back/house_change_list.html'

    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path(
                'extend-ownership-by-days', # 여기 주소를 form action에서 사용
                self.admin_site.admin_view(self.extend_ownership_by_days),
                name='extend-ownership-by-days' # 혹은 reverse 를 사용해서 해도 된다.
            ),
            ...
        ]
        dest = urls + my_urls
        return dest
		
    @staticmethod
    def extend_ownership_by_days(request):
        categories = {
            'all': [Ownership.Category.LONG_TERM, Ownership.Category.SHORT_TERM],
            'longTerm': [Ownership.Category.LONG_TERM],
            'shortTerm': [Ownership.Category.SHORT_TERM]
        } # 1

        category = request.POST.get('category', None)
        days = request.POST.get('days', None)

        if not (category and days): # 2
            raise ValidationError('현재 요청 주신 데이터가 이상합니다. 확인해주세요.')

        Ownership.objects.filter(
            ended__gt=timezone.now(),
            category__in=categories[category]
        ).update(ended=F('ended')+Value(datetime.timedelta(days=int(days))))
				# 3
        return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/'))

보면 알겠지만 html을 작성하는 부분 이외에는 그렇게 어렵지는 않다.

 

그냥 radio를 통해서 category 와 days를 입력받아 admin에 넘기는 방식이다.

중요한 것은 form 의 action 이 admin에서 제공하는 get_urls 와 경로가 맞아야한다.

지금은 'extend-ownership-by-days' 로 함수를 연결했기 때문에 이 경로로 form action을 설정한다.

그러면 해당 경로로 들어온 요청은 def extend_ownership_by_days(request): 여기에서 처리하는데 간단히 설명하면

  1. category 의 들어오는 값에 맞춰서 필터링을 하기위해서 마련해뒀다.
  2. 데이터가 안 들어오는 경우만 검증하는 최소한의 검증이다
  3. 가장 중요한게 현재 세입자만 해야하다보니 계약기간이 남고, category에 포함되는 경우만 일단 필터 후 ended 필드에 값을 읽어서 days 를 더한 후 저장한다.
💡 from django.db.models import Value, F
ValueF 는 유용하게 쓸 수 있다보니 알아두면 좋다.
방금 코드를 다시작성하려고 하면
ownerships = Ownership.objects.filter(
            ended__gt=timezone.now(),
            category__in=categories[category]
        )

# 첫번째 방법 (선호)
bulks = []

for ownership in ownerships:
    bulks.append(
    	Ownership(
     	    id=ownership.id, 
            ended=ownership.ended + datetime.timedelta(days=int(days))
            )
        )

Ownership.objects.bulk_update(bulks, fields=['ended'])

# 두번째 방법 (비선호)
for ownership in ownerships:
    ownership.ended = ownership.ended + datetime.timedelat(days=int(days))
    ownership.save()
💡 이런 코드가 나오는데 사실 성능의 이슈가 생길 수 있다.

첫번째 방법의 경우 2번의 쿼리가 동작한다. filter() 에서 한번, bulk_update에서 한번 총 두번 그나마 양호하다.

하지만 두번째 방법의 경우는 별로다. filter() 에서 한번 일어나고 ownerships 의 갯수에 따라서 쿼리문이 생성되다보니 비효율적인 작업이 될 확률이 높다.

그러다보니 처음에 Value 와 F 를 쓰는 방식이 더욱 선호가 된다. 한번의 쿼리문으로 끝나니 말이다.
💡 해당 부분의 예제가 export_csv 로 많이 나와서 그렇게 쓰는 곳이 많지만 조금만 틀어보면 해당 방식을 유용하게 쓸 수 있는 방법이 많다. 방금 예제만 봐도 건물 리스트 페이지에서 계약을 건들고 있으니 말이다.

 

여담

사실 admin을 만지는 것은 그렇게 크게 재미있으면서도 재밌지는 않다.

재미있을 때에는 코드가 변경될 때마다 확인이 가능할 때이고 재미없을 때는 html 만질 때다.

 

사실 이거는 그냥 내가 html 관련으로 보기만 해도 별로 좋아하지 않는다.

좋아하는 사람은 나름 재미있게 할 것 같다.

 

Notion 에 작성한 것을 그대로 복사해서 가져오니 코드에 indent가 문제가 있었다.

그래서 수정을 하기는 했지만 완벽하지 않을 수도 있다.

코드

조금 설명의 목적에 맞추어지다보니 사실 설계적으로 봤을 때는 안 좋다.

감안하고 봐야한다.

혹시라도 아예 틀리면 알려주시면 수정을 하겠습니다.

# admin.py
import datetime
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db.models import Value, F
from django.http import HttpResponseRedirect
from django.urls import reverse, path
from django.utils import timezone
from django.utils.html import format_html

from .models import House, Ownership, Person

@admin.action(description='세입자 계약 만료')
def expire_ownership(modeladmin, request, queryset):
    for q in queryset:
        q.ownerships.filter(
            category__in=[
                Ownership.Category.LONG_TERM,
                Ownership.Category.SHORT_TERM
            ]
        ).update(ended=timezone.now())

class HouseAdminFilter(admin.SimpleListFilter):
    title = '계약'

    parameter_name = 'ownership_category'

    def lookups(self, request, model_admin):
        return (
            ('empty', '세입자 없음'),
            (Ownership.Category.LONG_TERM, '전세'),
            (Ownership.Category.SHORT_TERM, '월세'),
        )

    def queryset(self, request, queryset):
        if self.value():
            return queryset.filter(ownership_category=self.value())

class HouseAdmin(admin.ModelAdmin):
    list_display = [
        'id', 'get_current_owner', 'get_current_tenant', 'get_current_ownership_category',
        'category', 'post_number', 'address', 'detail_address', 'get_extend_one_year_button'
    ]
    ordering = [
        'id', 'category', 'post_number', 'address', 'detail_address'
    ]
    search_fields = ['category', 'post_number', 'address', 'detail_address', 'current_owner', 'current_tenant']
    actions = ['expire_ownership']
    change_list_template = 'back/house_change_list.html'
    list_filter = [HouseAdminFilter]

    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path(
                'extend-ownership-by-days',
                self.admin_site.admin_view(self.extend_ownership_by_days),
                name='extend-ownership-by-days'
            ),
            path(
                '<int:object_id>/extend-one-year',
                self.admin_site.admin_view(self.extend_one_year),
                name='extend-one-year'
            ),
        ]
        dest = urls + my_urls
        return dest

    @admin.action(description='세입자 계약 만료')
    def expire_ownership(self, request, queryset):
        for q in queryset:
            q.ownerships.filter(
                category__in=[
                    Ownership.Category.LONG_TERM,
                    Ownership.Category.SHORT_TERM
                ]
            ).update(ended=timezone.now())

    @admin.display(ordering='current_owner', description='현재 건물 주인')
    def get_current_owner(self, obj):
        return obj.current_owner

    @admin.display(ordering='current_tenant', description='현재 건물 세입자')
    def get_current_tenant(self, obj):
        return obj.current_tenant

    @admin.display(ordering='ownership_category', description='현재 건물 계약상태')
    def get_current_ownership_category(self, obj):
        categories = {
            'empty': '현재 세입자 없음',
            'LongTerm': '전세',
            'ShortTerm': '월세',
        }
        return categories.get(obj.ownership_category)

    @admin.display(description='현재 세입자 1년 연장하기')
    def get_extend_one_year_button(self, obj):
        ownerships = obj.ownerships.filter(
            category__in=[
                Ownership.Category.LONG_TERM,
                Ownership.Category.SHORT_TERM
            ],
            ended__gt=timezone.now(),
        ).order_by('-started')

        if not ownerships:
            return format_html(
                '<span>현재 세입자가 존재하지 않습니다.</a>',
            )

        ownership_pk = ownerships.first().id

        return format_html(
            '<a class="button" href="{}">1년 연장</a>',
            reverse('admin:extend-one-year', args=[ownership_pk])
        )

    @staticmethod
    def extend_one_year(request, object_id):
        try:
            ownership = Ownership.objects.get(id=object_id)
            ownership.ended = ownership.ended + datetime.timedelta(days=365)
            ownership.save()
        except Ownership.DoesNotExist:
            raise ValidationError('Does not exist')
        return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/'))

    @staticmethod
    def extend_ownership_by_days(request):
        categories = {
            'all': [Ownership.Category.LONG_TERM, Ownership.Category.SHORT_TERM],
            'longTerm': [Ownership.Category.LONG_TERM],
            'shortTerm': [Ownership.Category.SHORT_TERM]
        }

        category = request.POST.get('category', None)
        days = request.POST.get('days', None)

        if not (category and days):
            raise ValidationError('현재 요청 주신 데이터가 이상합니다. 확인해주세요.')

        Ownership.objects.filter(
            ended__gt=timezone.now(),
            category__in=categories[category]
        ).update(ended=F('ended') + Value(datetime.timedelta(days=int(days))))

        return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/'))

class OwnershipAdmin(admin.ModelAdmin):
    list_display = ['id', 'owner', 'house', 'category', 'amount', 'started', 'ended']
    ordering = ['id', 'owner', 'house', 'category', 'amount', 'started', 'ended']
    search_fields = ['id', 'owner__name', 'house__address', 'category', 'amount', 'started', 'ended']

class PersonAdmin(admin.ModelAdmin):
    list_display = ['id', 'name', 'age']
    ordering = ['id', 'name', 'age']
    search_fields = ['id', 'name', 'age']

admin.site.register(House, HouseAdmin)
admin.site.register(Ownership, OwnershipAdmin)
admin.site.register(Person, PersonAdmin)
# models.py
from django.db import models
from django.db.models import OuterRef, When, Case, Exists, Value
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

class HouseQuerySet(models.QuerySet):
    def current_owner(self):
        owner_names = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category=Ownership.Category.BUY,
        ).order_by('-started').values('owner__name')
        return self.annotate(
            current_owner=owner_names[:1]
        )

    def current_tenant(self):
        tenant_names = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category__in=[Ownership.Category.LONG_TERM, Ownership.Category.SHORT_TERM],
            ended__gt=timezone.now()
        ).order_by('-started').values('owner__name')
        return self.annotate(
            current_tenant=tenant_names[:1]
        )

    def current_status(self):
        categories = Ownership.objects.filter(
            house_id=OuterRef('pk'),
            category__in=[Ownership.Category.LONG_TERM, Ownership.Category.SHORT_TERM],
            ended__gt=timezone.now()
        ).order_by('-started').values('category')

        return self.annotate(
            ownership_category=Case(
                When(condition=Exists(categories[:1]), then=categories[:1]),
                default=Value('empty')
            )
        )

class HouseManager(models.Manager):
    def get_queryset(self):
        return HouseQuerySet(self.model).current_tenant().current_owner().current_status()

class Person(models.Model):
    name = models.CharField(_('이름'), max_length=50)
    age = models.IntegerField(_('나이'))

    class Meta:
        verbose_name = '사람'
        verbose_name_plural = '사람'

    def __str__(self):
        return f'{self.name}({self.age})'

class House(models.Model):
    class Category(models.TextChoices):
        APARTMENT = 'Apartment', _('아파트')
        House = 'House', _("단독 주책")
        Studio = 'Studio', _('원룸')

    post_number = models.CharField(_('우편 번호'), max_length=10)
    address = models.CharField(_('주소'), max_length=100)
    detail_address = models.CharField(_('상세 주소'), max_length=100)
    category = models.CharField(_('종류'), choices=Category.choices, max_length=50)

    objects = HouseManager()

    class Meta:
        verbose_name = '건물'
        verbose_name_plural = '건물'

    def __str__(self):
        return f"[{self.post_number}] {self.address} 건물"

class Ownership(models.Model):

    class Category(models.TextChoices):
        BUY = 'Buy', _('매매')
        LONG_TERM = 'LongTerm', _('전세')
        SHORT_TERM = 'ShortTerm', _('월세')

    owner = models.ForeignKey(Person, on_delete=models.DO_NOTHING, related_name='ownerships', verbose_name=_('계약 주체'))
    house = models.ForeignKey(House, on_delete=models.DO_NOTHING, related_name='ownerships', verbose_name=_('계약 물건'))
    category = models.CharField(_('종류'), choices=Category.choices, max_length=50)
    amount = models.IntegerField(_('금액'))
    started = models.DateField(_('시작 날짜'))
    ended = models.DateField(_('종료 날짜'), null=True, blank=True)

    class Meta:
        verbose_name = '계약'
        verbose_name_plural = '계약'

    def __str__(self):
        return f'{self.owner.name}의 {self.get_category_display()} 계약'
{% extends 'admin/change_list.html' %}
{% block object-tools %}
    <div>
        <form action="extend-ownership-by-days" method="POST">
            {% csrf_token %}
                <strong>항목</strong> :
                <input type="radio" id="categoryAll" name="category" value="all">
                <label for='categoryAll'>전체</label>
                <input type="radio" id="longTerm" name="category" value="longTerm">
                <label for='longTerm'>전세</label>
                <input type="radio" id="shortTerm" name="category" value="shortTerm">
                <label for='shortTerm'>월세</label>
                <br  />
                <br  />
                <strong>증가 일수</strong> :
                <input type="number" id='days' name="days">
                <br />
                <br />
                <button class="button" style="margin-bottom: 1rem" type="submit">일수 늘리기</button>
        </form>
    </div>
    {{ block.super }}
{% endblock %}