django를 많이 다뤄보지 않은 사용자라면 제목을 보고 이게 대체 무슨 말인가 싶을것이다.
filter_horizontal
는 ManyToMany
필드를 더 효율적으로 보고 변경할 수 있도록 되어있는 어드민 폼의 항목인데, 사진으로 보는 것이 더 이해하기 쉬울 것이다.
예시를 위해 현재 어드민에 django에서 기본으로 제공하는 장고유저 관련 어드민을 추가해서 한번 내용을 확인해보자.
#adminpage\admin.py
...
from django.contrib.auth.models import User as AuthUser, Group
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin, GroupAdmin
admin.ModelAdmin.list_per_page = 20
admin_site = admin.AdminSite(name='yourwish')
...
class UserAdmin(BaseAdmin):
...
# django authentication 관련 어드민 등록
admin_site.register(AuthUser, AuthUserAdmin)
admin_site.register(Group, GroupAdmin)
django.contrib.auth
쪽에서 유저, 그룹 모델을 가져와서 django.contrib.auth
에서 제공하는 UserAdmin
과 GroupAdmin
을 어드민 기본 형태로 사용해 어드민 사이트에 등록했다.
그룹을 몇개 만들고 유저 상세 항목에 들어가보면 다음과 같은 부분이 있다.
이렇게 나타나는 부분이 filter_horizontal
이다.
django에서 자동 생성된 M2M 테이블의 경우
Admin
에filter_horizontal
변수에 M2M 필드명을 입력하는 간단한 방법으로 위와 같은 형태를 표현할 수 있다.
무슨 말인지 굳이 이해하려고 하지 않아도 괜찮다.
한번 쭉 읽고 다시 올라오면 한번에 이해할 수 있을것이다.
먼저 유저와 유저간의 친구 관계가 있다고 해보자.
ManyToManyField의 첫번째 인자로 교차테이블이 아닌 교차할 대상 테이블명을 전달하면 교차 테이블이 구성 된다. 현재는 자기 자신과 교차해야 하므로 "self"를 전달한다.
# adminpage\models.py
...
class User(models.Model):
...
friend = models.ManyToManyField("self", blank=True)
...
python manage.py makemigrations
과 python manage.py migrate
을 진행해 django가 자동으로 M2M테이블을 실제 데이터베이스에 생성하도록 한다.
데이터베이스를 열어보면 user_friend
테이블이 새로 생성된 것을 확인할 수 있다.
이제 friend 항목을 어드민에 추가하자.
class UserAdmin(BaseAdmin):
list_display = ('id', 'user_name', 'recent_login','isRecentlyLogined2')
fields = ('id',
('user_img_url', 'imgThumbnail'),
'user_name','user_pass',
('recent_login','isRecentlyLogined2'),
# 이부분 추가
'friend'
)
...
이렇게만 하면 다음과 같이 나타난다.
위에서 장고 기본 유저 어드민과는 다르게 보이는데, 장고 기본 유저 어드민과 동일하게 보이도록 수정하는 방법은 간단하다.
어드민에 filter_horizontal
에 M2M 필드명을 추가해주면 된다.
class UserAdmin(BaseAdmin):
list_display = ('id', 'user_name', 'recent_login','isRecentlyLogined2')
fields = ('id',
('user_img_url', 'imgThumbnail'),
'user_name','user_pass',
('recent_login','isRecentlyLogined2'),
'friend')
# 이부분 추가
filter_horizontal=('friend',)
...
원하는 형태로 잘 나타난 것을 확인할 수 있다.
업적 테이블이 있고, 사용자가 특정 행동을 하면 업적과 사용자의 교차테이블에 로그가 하나씩 쌓인다고 치자.
이때 사용자는 on/off로 업적이 보이지 않게 변경할 수 있다.
이 경우 교차테이블에 on/off 필드가 추가로 필요하므로, 모델에 ManyToManyField
선언을 통해 간단하게 교차테이블을 생성할 수 없다.
수동으로 교차테이블을 만들고 사용자와 업적을 엮어야 한다.
models.py에 다음 모델을 추가하자.
# adminpage\models.py
...
class Achivement(models.Model):
id = models.AutoField(primary_key=True)
achiv_name = models.CharField(verbose_name="업적명",max_length=200)
def __str__(self):
return f"{self.achiv_name}"
class Meta:
managed = True
db_table = 'achivement'
verbose_name_plural = '업적'
class UserAchivement(models.Model):
id = models.AutoField(primary_key=True)
user_id= models.ForeignKey(User, on_delete=models.CASCADE, db_column="user_id", related_name="userAchiv_user")
achiv_id= models.ForeignKey(Achivement, on_delete=models.CASCADE, db_column="achiv_id", related_name="userAchiv_achiv")
visible_chk= models.BooleanField(default=True)
def __str__(self):
return f"{self.user_id}_{self.achiv_id}"
class Meta:
managed = True
db_table = 'user_achivement'
verbose_name_plural = '유저-업적'
python manage.py makemigrations
과 python manage.py migrate
을 진행해 변경사항을 실제 데이터베이스에 적용한다.
achivement 테이블과 교차테이블이 잘 생성된 것을 확인할 수 있다.
이제 유저에 이미 존재하는 교차테이블을 M2M 필드로 엮어주어야 한다.
ManyToManyField의 첫번째 인자로 교차할 대상 테이블인 Achivement
을 전달하고, through={교차테이블명}
옵션으로 교차 테이블 명을 함께 전달한다.
[교차할 대상 테이블] 은 목적지고
[교차 테이블]은 목적지로 가기 위한 다리라고 생각하면 된다.[기존 테이블 A] <--- 교차테이블 ---> [교차할 대상 테이블 B]
# adminpage\models.py
class User(models.Model):
...
achives = models.ManyToManyField(
'Achivement', through='UserAchivement')
기본적인 세팅은 완료되었다.
이 achives
가 어드민에서 어떻게 보이는지 확인해보자.
# adminpage\admin.py
class UserAdmin(BaseAdmin):
list_display = ('id', 'user_name', 'recent_login','isRecentlyLogined2')
fields = ('id',
('user_img_url', 'imgThumbnail'),
'user_name','user_pass',
('recent_login','isRecentlyLogined2'),
'friend',
# 추가한 부분
'achives'
)
유감스럽게도 에러가 발생한다.
수동으로 만든 ManyToManyField
라서 Admin
의 fields
에 achives
필드를 추가할 수 없다고 한다.
즉 수동으로 만든 M2M필드를 django에서 자동생성한 M2M필드처럼 폼에 나타내려면 새로운 방법을 사용해야 한다.
admin.ModelAdmin
는 기본 모델폼을 사용한다.
즉 기본 폼의 형태는 다음과 같다.
class baseUserForm(forms.ModelForm):
class Meta:
model = User
fields = '__all__'
이 기본 폼을 수정해서 어드민에 적용시킬 것이다.
먼저 Form에 커스텀 필드를 추가한다.
# adminpage\admin.py
class ManytoManyAdminForm(forms.ModelForm):
AchiveList = forms.ModelMultipleChoiceField(
queryset=Achivement.objects.all(),
required=False,
widget=FilteredSelectMultiple(
verbose_name='업적',
is_stacked=False
),
label='업적 리스트',
)
class Meta:
model = User
fields = '__all__'
forms.ModelMultipleChoiceField
는 M2M 필드 기본 폼 필드 형식이다.
이 필드에 전달한 옵션을 하나씩 살펴보자.
queryset
M2M 필드 좌측에 나타나는 값들을 전달한다.
즉,User
와 엮을 수 있는 모든Achivement
가 전달 되어야 한다.
따라서 Achivement.objects.all()로 전체 쿼리셋을 전달했다.required
이 값이 필수적으로 값이 들어있어야 하는지를 결정한다.
값이 없어도 무관하므로 false를 전달한다.wiget
이 필드가 폼 페이지에 그려지는 방식을 전달한다. 이 필드가 없을 경우, 다음과 같이 나타난다.
wiget 필드가 추가된 경우 다음과 같이 나타난다.
즉, wiget필드에 값을 전달하여filter_horizontal
과 같은 효과를 낼 수 있다.
여기에서 전달한 값은FilteredSelectMultiple
인데
verbose_name
으로 라벨 값을 지정했고
(Available "라벨명"
으로 화면에 나타난다)
is_stacked
에False
를 지정하여 좌측 박스와 우측 박스가 한 row에 나타나도록 했다.
(is_stacked
에True
값을 주면 좌측 박스와 우측 박스가 한 column에 나타난다.label
표 왼쪽에 나타나는 라벨 값이다.
이렇게 하면 수동 생성한 M2M 필드를 자동생성한 필드와 동일하게 화면에 보여줄 수 있다.
하지만 아직 끝난게 아니다.
업적리스트에서 업적을 추가하고 저장하기를 눌러보면...
아무일도 생기지 않는다!
폼에 새로 추가한 필드를 통해 수정 되는 정보는 자동으로 처리되지 않는다.
따라서 폼이 저장될 때 필드의 값을 확인하고 교차테이블을 수정하는 작업을 직접 추가해주어야 한다.
위에서 작업한 form에서 save 함수를 오버라이딩 한다.
# adminpage\admin.py
class ManytoManyAdminForm(forms.ModelForm):
...
def save(self, commit=True):
obj = super().save(commit)
if obj.pk:
try:
obj.achives.set(
self.cleaned_data['AchiveList'], clear=True)
except Exception as e:
print(e)
return obj
M2M 필드는 User
모델에서 지정한 ManyToManyField
로 간편하게 수정할 수 있다.
obj.achives.all(q)
: 전체 연관 항목 불러오기obj.achives.add(q)
: 연관 항목 추가obj.achives.remove(q)
: 연관 항목 삭제obj.achives.clear()
: 연관 항목 전체 삭제obj.achives.set(qs, clear)
- clear=True: 기존 연관 항목 전체 삭제 후 전달받은 연관 항목 전체 추가
- clear=False: 기존 연관 항목 유지 + 전달받은 연관 항목 전체 추가
위 코드에서는 먼저 부모의 save
를 실행하여 저장 한 후, pk를 확인한다.
현재 오브젝트의 id 는 AutoField
이므로 저장되지 않은 경우에는 pk가 존재하지 않는다.
pk가 존재하면 연관 항목에서 기존 항목을 모두 지우고 전달받은 항목을 전체 추가한다.
저장은 했지만 저장된 항목을 최초에 불러와서 채워주는 부분이 없다.
현재로서는 데이터베이스에서 정상적으로 저장 되었고 값이 존재 하더라도 그 값을 불러오는 부분이 없어 chosen 항목은 항상 비어있을 것이다.
그러니 __init__
함수를 오버라이딩 해 최초 값을 채워보자.
# adminpage\admin.py
class ManytoManyAdminForm(forms.ModelForm):
...
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields['AchiveList'].initial = self.instance.achives.all()
# 로그 커스텀에 이어서 진행중인 경우 이 부분 추가
self.initial['AchiveList'] = self.fields['AchiveList'].initial
완성되었다.
이제 항목을 추가하고 저장해보자.
성공적으로 저장되었다!
지난 글에서 로그 수정에 대해 다뤘는데, 여기까지만 진행하면 업적 리스트의 변경사항은 로그에 저장되지 않는다.
로그까지 찍으려면 저장부분을 조금 더 손봐야 한다.
# adminpage\admin.py
...
class ManytoManyAdminForm(forms.ModelForm):
...
def save(self, commit=True):
obj = super().save(commit)
initial = self.fields['AchiveList'].initial
diff = self.cleaned_data['AchiveList'].difference(initial).count() + initial.difference(self.cleaned_data['AchiveList']).count()
if obj.pk and diff > 0 :
try:
ori_pks = list(initial.values_list('id' , flat=True))
obj.achives.set(
self.cleaned_data['AchiveList'], clear=True)
self.initial['AchiveList'] = Achivement.objects.filter(pk__in=ori_pks)
except Exception as e:
print(e)
return obj
먼저 진짜 변경이 되었는지 부터 확인한다.
difference를 통해 두 쿼리셋의 차집합을 구할 수 있다.
A-B, B-A를 한 뒤 원소의 개수를 더했을때 0보다 크면 변경사항이 있는 것이다.
변경사항이 있을때만 set으로 실제 값을 수정하도록 변경했다.
그리고 로그를 생성할 때 form.initial
과 form.cleaned_data
두가지를 사용 했던 것을 기억할 것이다.
form은 form.initial
과 form.cleaned_data
가 다르면 알아서 form.changed_data
에 필드를 추가하므로, form.initial
만 이전 값으로 잘 세팅해 주면 로그가 정상적으로 생성될 것이다.
진행하다가 이상한 점을 발견했는데,
prev = obj.M2M_field.all()
을 통해 값을 저장해둔 뒤,
obj.M2M_field.clear()
로 전체 값을 지우고
print(prev)
를 하면 빈값이 나온다.
deepcopy를 해도 동일한 결과가 나왔다.
이유가 뭘까 고민했는데, 변수에 저장했더라도 부를때마다 sql을 새로 실행하는게 아닐까 하는 의심에 도달했다.
settings.py에 다음 내용을 넣으면 sql문을 콘솔로 확인 할 수 있다.
sql logging : 스택오버플로우LOGGING = { 'version': 1, 'filters': { 'require_debug_true': { '()': 'django.utils.log.RequireDebugTrue', } }, 'handlers': { 'console': { 'level': 'DEBUG', 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', } }, 'loggers': { 'django.db.backends': { 'level': 'DEBUG', 'handlers': ['console'], } } }
해당 설정을 해준 뒤 다음과 같이 뽑아보았다.
... class ManytoManyAdminForm(forms.ModelForm): ... def save(self, commit=True): obj = super().save(commit) initial = self.fields['AchiveList'].initial diff = self.cleaned_data['AchiveList'].difference(initial).count() + initial.difference(self.cleaned_data['AchiveList']).count() if obj.pk and diff > 0 : try: print("###########################\n", initial,"\n###########################\n", ) ori_pks = list(initial.values_list('id' , flat=True)) obj.achives.set( self.cleaned_data['AchiveList'], clear=True) print("###########################\n", initial,"\n###########################\n", ) self.initial['AchiveList'] = Achivement.objects.filter(pk__in=ori_pks) except Exception as e: print(e) return obj ...
보이는 바와 같이 변수에 넣더라도 부를때마다 sql을 호출하고 있었다.
공식 문서도 같이 살펴보았는데,you can get updated results for the same query by calling all() on a previously evaluated QuerySet.
부분을 보니 쿼리셋은 일반적으로 결과를 캐싱하지만, all()을 사용하면 항상 업데이트 된 결과를 가져온다는 것을 알 수 있었다.
즉, all()은 캐싱을 하지 않는다...
그러한 이유로, all()로 값을 가져오고 id만 꺼내서 리스트로 값을 고정시킨 다음, 필요한 곳에서 filter로 이전 all이 가지고 있던 Achivement를 추려서 가져오는 방식을 사용했다.
이제 업적리스트 필드를 수정하고 다시 저장해보면 커스텀 M2M 필드의 로그도 기존에 지정한대로 잘 저장되는 것을 확인할 수 있다.
로그 수정도 완료되었다!
https://github.com/hokim2407/django-admin_study/tree/b35e3b60a4eae62407305b4055d2317e83f2d095