Django Admin-5 : 로그 구성하기

Ho Kim·2022년 11월 9일
1

django admin은 기본적으로 히스토리를 제공한다.

얼핏보면 좋아보이지만 내용을 보면 빈약하기 그지없다.

어떤 항목을 어떤 값에서 어떤 값으로 수정했는지는 나오지 않고 단순히 항목이 수정되었는지 여부만 알려주는데, 오늘은 이 로그를 상세하게 바꿔볼 것이다.

1. ModelAdmin 살펴보기

UserAdmin 이 상속받아 사용중인 admin.ModelAdmin을 살펴보면 log_addition, log_change, log_deletion 이 세 함수가 위에 보이는 히스토리 로그를 생성한다는 것을 알 수 있다.

내용은 거의 동일하므로 log_change 하나만 살펴보자.


히스토리의 내용은 message라는 인자로 채워진다.
log_change 함수가 사용되는 부분은 _changeform_view 함수이다.

내용을 살펴보면 log_change에 인자로 message를 넘겨주기 위해 construct_change_message 로 메세지를 생성하는 것을 확인 할 수 있는데,

이 함수는 django.contrib.admin.utils에 포함된 construct_change_message를 반환한다.

위 사진이 ModelAdmin의 construct_change_message이고,

위 사진이 django.contrib.admin.utils에 포함된 construct_change_message이다.

ModelAdmin의 construct_change_message을 오버라이딩해 필요한 상세 정보 메세지를 리턴하도록 변경하여 사용할 것이다.

2. construct_change_message 오버라이딩

이후에 추가될 모든 어드민에 적용하기 위해 baseAdmin.py를 새로 만들자.

# adminpage\baseAdmin.py
from django.contrib import admin

class BaseAdmin(admin.ModelAdmin):
	#내용 채울 부분

그리고 UserAdminBaseAdmin을 상속받도록 만든다.

# adminpage\admin.py
...
from .baseAdmin import BaseAdmin

class UserAdmin(BaseAdmin):
...

django.contrib.admin.utils에 포함된 construct_change_message을 보면 크게 두 부분으로 나뉘어 있는 것을 볼 수 있다.

첫번째는 폼이고 두번째는 폼셋이다.

폼은 change view에 있는 기본 폼이고, 폼셋은 인라인으로 참조된 인스턴스들에 대한 폼 모음이다.

+) 폼셋이란

폼셋에 대한 예시를 위해 address 테이블을 추가해 보자.

#adminpage\models.py

...

class Address(models.Model):
	id = models.AutoField(primary_key=True)
	addr1 = models.CharField(verbose_name="주소 1",max_length=200)
	addr2 = models.CharField(verbose_name="주소 2",max_length=200)
	postcode = models.CharField(verbose_name="우편번호",max_length=200)
	updated_at = models.DateTimeField(auto_now=True, verbose_name="수정일", )
	created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일", )

	user_id = models.ForeignKey(User,  on_delete=models.CASCADE, 
    	db_column="user_id", related_name="userAddr")

	def __str__(self):
		return f"[{self.postcode}] {self.addr1},{self.addr2}"

	class Meta:
		managed = True
		db_table = 'address' 
		verbose_name_plural = '주소' 

모델 수정 후에는 반드시 python manage.py makemigrationspython manage.py migrate를 해야 한다는 걸 잊지 말자.

유저 어드민으로 돌아가 inline 모델을 만들고 User 모델에 인라인을 추가한다.

...

class AddrInline(admin.TabularInline):
    model = Address
    readonly_fields = ('created_at', 'updated_at')
    extra = 0
    

class UserAdmin(admin.ModelAdmin):
	...
    inlines=[AddrInline]
    ...

이렇게 하면 유저 어드민 하단에 User를 참조하는 Address 인스턴스들이 나타나는 inline formset이 추가된다.

1) 폼 메세지 만들기

폼으로부터 변경사항을 뽑는 함수를 만들자.
먼저 기존 메세지가 어떤 형식인지를 알아야 하므로 log_change에서 메세지를 뽑아본다.

    def log_change(self, request, object, message):
        print(message)
        """
        Log that an object has been successfully changed.

        The default implementation creates an admin LogEntry object.
        """
        from django.contrib.admin.models import CHANGE, LogEntry
        return LogEntry.objects.log_action(
            user_id=request.user.pk,
            content_type_id=get_content_type_for_model(object).pk,
            object_id=object.pk,
            object_repr=str(object),
            action_flag=CHANGE,
            change_message=message,
        )

이름과 비밀번호를 바꾸고 뽑으니 [{'changed': {'fields': ['이름', '비밀번호']}}]로 나타났다.

결과 메세지를 동일한 형식으로 뽑아낸다.

# adminpage\baseAdmin.py
from django.contrib import admin

class BaseAdmin(admin.ModelAdmin):
	def getLogMessage(self, form, add=False):
    	# form.changed_data에는 값이 변경된 필드의 리스트가 들어있다
		changed_data = {} if form is None else form.changed_data
		data = {}
		change_message = []
        
		if add:
        	# 추가된 경우에는 별도 내용 없이 added 만 넣는다.
			change_message.append({'added': data})
		elif form.changed_data:
        	# 변경된 경우 message 배열 안에 변경사항을 쌓는다.
            
			message = []
			# 값이 변경된 필드를 모두 확인한다.
			for field in changed_data:
    			# 최초값은 form.initial에 dict 형식으로 들어있다.
				initial = form.initial[field]
                
    			# 수정값은 form.cleaned_data에 dict 형식으로 들어있다.
				cleaned_data = form.cleaned_data[field]
                
                # field는 model에 선언된 변수명이므로 
                # vervose name으로 출력하기 위해 form.fields[field].label 로 뽑는다.
                # [필드명] "기존값" => "새값"
				message.append(
					f"""[{form.fields[field].label}] "{str(initial)}" => "{str(cleaned_data)}" """)
			data['fields'] = message
			change_message.append({'changed': data})
		return change_message
	
	def construct_change_message(self, request, form, formsets, add=False):
		# 기본 폼 메세지 구성
		change_message = self.getLogMessage(form, add)
		return change_message

이제 이름을 변경하고 히스토리를 확인하면 원하는 대로 결과가 나타나는 것을 볼 수 있다.

1) 폼셋 메세지 만들기

인라인 값을 변경 할 경우 메세지는 다음과 같이 나타난다.

이것도 자세한 값이 나타나도록 변경해보자.

forset내부에는 Address 모델에 대한 form이 각각 들어있으므로 내부적인 동작은 동일하다.
따라서 다른 부분은 변경하지 않고 inline 값이 신규 생성되었을 때의 정보만 추가로 입력할 것이다.

	def getLogMessage(self, form, add=False, formsetObj=None):
		changed_data = {} if form is None else form.changed_data
		data = {}
		change_message = []
        
		# 인라인 추가시 상세 정보 입력
		if formsetObj is not None:
			data = {'name': str(formsetObj._meta.verbose_name_plural),
					'object': f"{str(formsetObj)}({formsetObj.pk})", }
		if add:
			change_message.append({'added': data})
		elif form.changed_data:
            
			message = []
			for field in changed_data:
				initial = form.initial[field]
				cleaned_data = form.cleaned_data[field]
                
				message.append(
					f"""[{form.fields[field].label}] "{str(initial)}" => "{str(cleaned_data)}" """)
			data['fields'] = message
			change_message.append({'changed': data})
		return change_message

폼에서 메세지 가져오는 부분을 완성했으니 construct_change_message를 수정해야한다.

Formsets와 Formset과 Form

construct_change_message는 formsets를 인자로 가져오는데, formsetsformset의 배열이다. 각 어드민은 여러개의 inline을 가질 수 있기 때문이다.

예시로 Address와 동일한 구조로 PhoneNumber를 만들었다고 가정해보면 User는 인라인으로 AddressPhoneNumber를 둘다 가질 수 있다.
이 경우 formsets는 다음과 같다고 볼 수 있다.
formsets = [Address_formset, PhoneNumber_formset]


여기에 더해 한 User는 복수개의 AddressPhoneNumber를 가질 수 있다.
즉, 다음과 같다.
Address_formset.forms = [addr_form1, addr_form2...],
PhoneNumber를_formset.forms = [phone_form1, phone_form2...]

django.contrib.admin.utils에 포함된 construct_change_message을 보면 formset은 변경사항을 폼이 아닌 인스턴스로 가지고있다.

신규 생성된 인스턴스들은 formset.new_objects,
업데이트된 인스턴스들은 formset.changed_objects,
삭제된 인스턴스들은 formset.deleted_objects에 들어있다.

메세지는 폼을 이용해 생성하므로 이 인스턴스와 폼을 매칭시켜주는 작업이 필요하다.

따라서 key를 pk, value를 forset의 form으로 가지는 formList를 먼저 만들고,
각 인스턴스를 돌면서 pk값 으로 폼을 조회해서 메세지를 생성할 것이다.

	def construct_change_message(self, request, form, formsets, add=False):
		# 기본 폼 메세지 구성
		change_message = self.getLogMessage(form, add)
  
		# 폼셋들이 존재할 경우
		if formsets:
			# 각 폼셋에 대해
			for formset in formsets:
				# pk를 key로, form을 value로 가지는 dict 구성
				formList = {}
                
                #formset으로부터 pk 필드 이름 가져오기
				pkName = ''
				if formset.__len__() > 0:
					pkName = formset.forms[0]._meta.model._meta.pk.name
                    
                # formset이 가진 각 form을 순회하면서 formList 채우기
				for singleform in formset.forms:
					try:
                    	# form의 pk는 해당 pk의 인스턴스임
						obj = singleform.cleaned_data[pkName]
                        
                    	# 변경되지 않은 경우 cleaned_data에 값이 없으므로 최초값을 확인
						if(obj is None):
							obj = singleform.initial.get(pkName)
                            
                        # obj가 존재하면 formList에 obj에서 pkName에 해당하는 값을 찾아 key로,
                        # form을 value로 저장
						if(obj is not None):
							formList[getattr(obj, pkName)] = singleform
					except Exception as e:
						print(e)

				# 신규 생성 된 인스턴스 순회하면서 메세지 생성
				for added_object in formset.new_objects:
					message = self.getLogMessage(
						None, True, formsetObj=added_object)
					change_message += message
     
				# 변경된 인스턴스 순회하면서 메세지 생성
				for changed_object, changed_fields in formset.changed_objects:
                	# pk로 폼 찾기
					singleForm = formList[changed_object.pk]
					message = self.getLogMessage(
						singleForm, False, formsetObj=changed_object)
					change_message += message
					
                    # 현재(User) 인스턴스가 아니라 
                    # 해당 인라인 인스턴스(Address)의 히스토리에도 변경내용 기록
					self.log_change(request, changed_object,
									self.getLogMessage(singleForm, False))
                                    
                # 삭제된 인스턴스 순회하면서 메세지 생성
				for deleted_object in formset.deleted_objects:
					change_message.append({
						'deleted': {
							'name': str(deleted_object._meta.verbose_name_plural),
							'object': str(deleted_object),
						}
					})

		return change_message

이렇게 하면

이렇게 User에서 inline 정보 변경을 상세하게 확인할 수 있다.


또한 주소 어드민의 히스토리에서도 위와 같이 변경 내역을 확인 할 수 있다.



상세 코드는 다음을 참고:

https://github.com/hokim2407/django-admin_study/tree/59bf20cf5447ad11f1e1a1ac4a9284f935c8fba4

0개의 댓글