django admin은 기본적으로 히스토리를 제공한다.
얼핏보면 좋아보이지만 내용을 보면 빈약하기 그지없다.
어떤 항목을 어떤 값에서 어떤 값으로 수정했는지는 나오지 않고 단순히 항목이 수정되었는지 여부만 알려주는데, 오늘은 이 로그를 상세하게 바꿔볼 것이다.
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
을 오버라이딩해 필요한 상세 정보 메세지를 리턴하도록 변경하여 사용할 것이다.
이후에 추가될 모든 어드민에 적용하기 위해 baseAdmin.py
를 새로 만들자.
# adminpage\baseAdmin.py
from django.contrib import admin
class BaseAdmin(admin.ModelAdmin):
#내용 채울 부분
그리고 UserAdmin
이 BaseAdmin
을 상속받도록 만든다.
# 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 makemigrations
와 python 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이 추가된다.
폼으로부터 변경사항을 뽑는 함수를 만들자.
먼저 기존 메세지가 어떤 형식인지를 알아야 하므로 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
이제 이름을 변경하고 히스토리를 확인하면 원하는 대로 결과가 나타나는 것을 볼 수 있다.
인라인 값을 변경 할 경우 메세지는 다음과 같이 나타난다.
이것도 자세한 값이 나타나도록 변경해보자.
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를 인자로 가져오는데,formsets
는formset
의 배열이다. 각 어드민은 여러개의 inline을 가질 수 있기 때문이다.
예시로Address
와 동일한 구조로PhoneNumber
를 만들었다고 가정해보면User
는 인라인으로Address
와PhoneNumber
를 둘다 가질 수 있다.
이 경우formsets
는 다음과 같다고 볼 수 있다.
formsets = [Address_formset, PhoneNumber_formset]
여기에 더해 한User
는 복수개의Address
와PhoneNumber
를 가질 수 있다.
즉, 다음과 같다.
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