안녕하세요. 이수호입니다. 이번에 사내 시스템을 새로 구축하면서 특정 모델에 대한 변경 사항이 기록되고, 이 변경 사항을 다시 revert 할 수 있어야 한다는 요구조건이 들어왔습니다. 몇개 안 되는 모델이라면 일일히 구현하겠지만 꽤나 많은 모델에 적용이 되어야 했고, 좀 더 범용성 있게 사용할 수 있는 방법이 필요했습니다.

Django Simple History

Django Simple History 는 이 문제를 해결해 줄 수 있었습니다. 사용법은 단순합니다. django-simple-history를 설치한 후 사용중인 모델에 다음과 같이 history Field를 추가해 주면 됩니다.

from django.db import models
from simple_history.models import HistoricalRecords

class Poll(models.Model):
    question = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')
    history = HistoricalRecords()

class Choice(models.Model):
    poll = models.ForeignKey(Poll)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)
    history = HistoricalRecords()

django-simple-history의 공식 도큐먼트에는 django 2.2까지만 지원하는 것으로 나와 있지만, pypi에는 3.0까지 지원하는 것으로 나와 있습니다.

이렇게 선언한 후 migration을 해 주면 테이블이 하나 생깁니다. 테이블명은 기본적으로 {modelname}_historical{modelname} 컨벤션을 따릅니다. 자세한 사용법은 공식 도큐먼트 를 참고해 주세요.

RESTful URL

이제 이 history를 json으로 던져주기 위한 URL 설계가 필요합니다. 선택지는 크게 두 가지가 있겠습니다.

GET: /{model_name}/history/{id}/ #좋지 않음 

혹은

GET: /{model_name}/{id}/history/{history_id}/ #좋음

REST의 정신을 따르면 좀 더 깔끔한 방법은 후자입니다. 하지만 Django에서(혹은 DRF에서) 후자 같은 url pattern을 구성하는 것은 생각보다 어렵습니다. 물론 이 방법도 Router를 상속받아 Custom Router를 구성하면 금방 하겠지만, 잘 만들어진 라이브러리가 있습니다.

DRF-nested-routers

drf-nested-routers 를 이용하면 해결 가능해집니다. 사용법은 간단합니다. drf-nested-routers 를 설치한 후 다음과 같이 사용하면 됩니다.

# urls.py
# rest_framework가 아닌 rest_framework_nested임을 주의해 주세요
from rest_framework_nested import routers
from views import DomainViewSet, NameserverViewSet
(...)

router = routers.SimpleRouter()
router.register(r'domains', DomainViewSet)

domains_router = routers.NestedSimpleRouter(router, r'domains', lookup='domain')
domains_router.register(r'nameservers', NameserverViewSet, base_name='domain-nameservers')

urlpatterns = patterns('',
    url(r'^', include(router.urls)),
    url(r'^', include(domains_router.urls)),
)
# views.py

class NameserverViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Nameserver.objects.filter(domain=self.kwargs['domain_pk'])

## OR: non-ORM resources ##

class NameserverViewSet(viewsets.ViewSet):
    def list(self, request, domain_pk=None):
        nameservers = self.queryset.filter(domain=domain_pk)
        (...)
        return Response([...])

    def retrieve(self, request, pk=None, domain_pk=None):
        nameservers = self.queryset.get(pk=pk, domain=domain_pk)
        (...)
        return Response(serializer.data)

자세한 사용법은 drf-nested-routers 저장소 를 참고해 주세요.

실제 사용 예시

저희는 serializer를 사용해서 history model에 대한 정보를 가져오고, 해당 history model에 PUT요청이 날아오면 그 history로 revert하는 기능이 필요했습니다. 따라서 serializer를 다음과 같이 구성했습니다.

class MyAwesomeModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = MyAwesomeModel
        fields = '__all__'

class MyAwesomeModelHistorySerializer(serializers.ModelSerializer):
    class Meta:
        model = MyAwesomeModel.history.model
        fields = '__all__'

원본 model과 그것에 대한 history model 각각에 대해 serializer를 정의해 주었습니다. 그 뒤 ViewSet에 다음과 같이 정의합니다.

class MyAwesomeModelViewSet(viewsets.ModelViewSet):
    queryset = MyAwesomeModel.objects.all()
    serializer_class = MyAwesomeModelSerializer
    filterset_fields = MyAwesomeModelSerializer.Meta.field

class MyAwesomeModelHistoryViewSet(viewsets.ModelViewSet):
    queryset = MyAwesomeModel.history.all()
    serializer_class = MyAwesomeModelHistorySerializer
    filterset_fields = MyAwesomeModelSerializer.Meta.fields

    def list(self, request, *args, **kwargs):
        history = get_list_or_404(self.queryset, id=kwargs['notice_pk'])
        serialized = MyAwesomeModelHistorySerializer(history, many=True)
        return Response(serialized.data)

    def update(self, request, *args, **kwargs):
        past = get_object_or_404(self.queryset, pk=kwargs['pk'])
        past.instance.save()
        serialized = MyAwesomeModelHistorySerializer(past)
        return Response(serialized.data)

마지막으로 앞에서 정의한 ViewSet을 router에 mapping 해 줍니다.

router = routers.SimpleRouter()
router.register(r'', NoticeViewSet, basename='my-awesome-model')

history_router = routers.NestedSimpleRouter(router, r'', lookup='my-awesome-model')
history_router.register(r'history', NoticeHistoryViewSet, base_name='my-awesome-model-history')

urlpatterns = router.urls + history_router.urls

app_name = 'my-awesome-model'

이제 다음과 같은 url이 route 됩니다.

GET: /my-awesome-model/
GET: /my-awesome-model/1/
GET: /my-awesome-model/1/histroy/
GET: /my-awesome-model/1/history/1/

마치며

간단하게 history를 기록하고, revert할 수 있는 라이브러리를 찾아서 작업량을 확 줄일 수 있었습니다. 둘 다 나름 사용자층이 탄탄하고, 유지보수가 계속 되고 있는 것으로 보아 필요하신 경우 사용하는 것도 괜찮아 보입니다. 감사합니다.