Django Admin-6 : 커스텀 M2M 필드 filter_horizontal로 나타내기

Ho Kim·2022년 11월 10일
0

django를 많이 다뤄보지 않은 사용자라면 제목을 보고 이게 대체 무슨 말인가 싶을것이다.
filter_horizontalManyToMany필드를 더 효율적으로 보고 변경할 수 있도록 되어있는 어드민 폼의 항목인데, 사진으로 보는 것이 더 이해하기 쉬울 것이다.

+) filter_horizontal이란?

예시를 위해 현재 어드민에 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에서 제공하는 UserAdminGroupAdmin을 어드민 기본 형태로 사용해 어드민 사이트에 등록했다.

그룹을 몇개 만들고 유저 상세 항목에 들어가보면 다음과 같은 부분이 있다.

이렇게 나타나는 부분이 filter_horizontal이다.

1. Django 자동생성 M2M 필드

django에서 자동 생성된 M2M 테이블의 경우 Adminfilter_horizontal 변수에 M2M 필드명을 입력하는 간단한 방법으로 위와 같은 형태를 표현할 수 있다.

무슨 말인지 굳이 이해하려고 하지 않아도 괜찮다.
한번 쭉 읽고 다시 올라오면 한번에 이해할 수 있을것이다.

1) 예시를 위한 친구 관계 M2M 테이블 생성

먼저 유저와 유저간의 친구 관계가 있다고 해보자.
ManyToManyField의 첫번째 인자로 교차테이블이 아닌 교차할 대상 테이블명을 전달하면 교차 테이블이 구성 된다. 현재는 자기 자신과 교차해야 하므로 "self"를 전달한다.

# adminpage\models.py
...


class User(models.Model):
	...
	friend = models.ManyToManyField("self", blank=True)
...

python manage.py makemigrationspython manage.py migrate을 진행해 django가 자동으로 M2M테이블을 실제 데이터베이스에 생성하도록 한다.

데이터베이스를 열어보면 user_friend 테이블이 새로 생성된 것을 확인할 수 있다.

2) 어드민에 M2M 필드 추가

이제 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' 
               )
     ...

이렇게만 하면 다음과 같이 나타난다.

3) 어드민에 나타나는 M2M 필드를 수정이 편하도록 변경

위에서 장고 기본 유저 어드민과는 다르게 보이는데, 장고 기본 유저 어드민과 동일하게 보이도록 수정하는 방법은 간단하다.

어드민에 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',) 
    
     ...


원하는 형태로 잘 나타난 것을 확인할 수 있다.

2. 커스텀 M2M 필드에 filter_horizontal 적용

1) 예시를 위한 업적 테이블과 유저-업적 교차테이블 생성

업적 테이블이 있고, 사용자가 특정 행동을 하면 업적과 사용자의 교차테이블에 로그가 하나씩 쌓인다고 치자.
이때 사용자는 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 makemigrationspython manage.py migrate을 진행해 변경사항을 실제 데이터베이스에 적용한다.


achivement 테이블과 교차테이블이 잘 생성된 것을 확인할 수 있다.

이제 유저에 이미 존재하는 교차테이블을 M2M 필드로 엮어주어야 한다.

ManyToManyField의 첫번째 인자로 교차할 대상 테이블Achivement을 전달하고, through={교차테이블명} 옵션으로 교차 테이블 명을 함께 전달한다.

[교차할 대상 테이블] 은 목적지고
[교차 테이블]은 목적지로 가기 위한 다리라고 생각하면 된다.

[기존 테이블 A] <--- 교차테이블 ---> [교차할 대상 테이블 B]
# adminpage\models.py

class User(models.Model):
	...
	achives = models.ManyToManyField(
        'Achivement', through='UserAchivement')

기본적인 세팅은 완료되었다.

2) 수동 생성한 필드 확인하기

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'
               )

유감스럽게도 에러가 발생한다.

<class 'adminpage.admin.UserAdmin'>: (admin.E013) The value of 'fields' cannot include the ManyToManyField 'achives', because that field manually specifies a relationship model.

수동으로 만든 ManyToManyField라서 Adminfieldsachives필드를 추가할 수 없다고 한다.

즉 수동으로 만든 M2M필드를 django에서 자동생성한 M2M필드처럼 폼에 나타내려면 새로운 방법을 사용해야 한다.

3) 수동생성 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 필드 기본 폼 필드 형식이다.

이 필드에 전달한 옵션을 하나씩 살펴보자.

  1. queryset
    M2M 필드 좌측에 나타나는 값들을 전달한다.
    즉, User와 엮을 수 있는 모든 Achivement가 전달 되어야 한다.
    따라서 Achivement.objects.all()로 전체 쿼리셋을 전달했다.

  2. required
    이 값이 필수적으로 값이 들어있어야 하는지를 결정한다.
    값이 없어도 무관하므로 false를 전달한다.

  3. wiget
    이 필드가 폼 페이지에 그려지는 방식을 전달한다. 이 필드가 없을 경우, 다음과 같이 나타난다.
    wiget 필드가 추가된 경우 다음과 같이 나타난다.
    즉, wiget필드에 값을 전달하여 filter_horizontal과 같은 효과를 낼 수 있다.

    여기에서 전달한 값은 FilteredSelectMultiple 인데
    verbose_name으로 라벨 값을 지정했고
    (Available "라벨명"으로 화면에 나타난다)

    is_stackedFalse를 지정하여 좌측 박스와 우측 박스가 한 row에 나타나도록 했다.
    (is_stackedTrue 값을 주면 좌측 박스와 우측 박스가 한 column에 나타난다.

  4. label
    표 왼쪽에 나타나는 라벨 값이다.

이렇게 하면 수동 생성한 M2M 필드를 자동생성한 필드와 동일하게 화면에 보여줄 수 있다.

하지만 아직 끝난게 아니다.
업적리스트에서 업적을 추가하고 저장하기를 눌러보면...

아무일도 생기지 않는다!

4) 수정 기능 붙이기

폼에 새로 추가한 필드를 통해 수정 되는 정보는 자동으로 처리되지 않는다.

따라서 폼이 저장될 때 필드의 값을 확인하고 교차테이블을 수정하는 작업을 직접 추가해주어야 한다.

위에서 작업한 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로 간편하게 수정할 수 있다.

  1. obj.achives.all(q) : 전체 연관 항목 불러오기
  2. obj.achives.add(q) : 연관 항목 추가
  3. obj.achives.remove(q) : 연관 항목 삭제
  4. obj.achives.clear() : 연관 항목 전체 삭제
  5. obj.achives.set(qs, clear)
    • clear=True: 기존 연관 항목 전체 삭제 후 전달받은 연관 항목 전체 추가
    • clear=False: 기존 연관 항목 유지 + 전달받은 연관 항목 전체 추가

위 코드에서는 먼저 부모의 save를 실행하여 저장 한 후, pk를 확인한다.

현재 오브젝트의 id 는 AutoField이므로 저장되지 않은 경우에는 pk가 존재하지 않는다.

pk가 존재하면 연관 항목에서 기존 항목을 모두 지우고 전달받은 항목을 전체 추가한다.

지난 로그 커스텀에 이어서 진행중인 경우 여기까지 진행 후 어드민 페이지에서 업적을 추가하고 저장했을때 오류가 발생할 것이다.
바로 밑의 5) M2M필드의 initial 값 불러오기를 진행해야 정상적으로 저장된다.

5) M2M필드의 initial 값 불러오기

저장은 했지만 저장된 항목을 최초에 불러와서 채워주는 부분이 없다.

현재로서는 데이터베이스에서 정상적으로 저장 되었고 값이 존재 하더라도 그 값을 불러오는 부분이 없어 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.initialform.cleaned_data 두가지를 사용 했던 것을 기억할 것이다.
form은 form.initialform.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

0개의 댓글