nikkie-ftnextの日記

イベントレポートや読書メモを発信

HerokuにデプロイしたDjangoアプリに、データを一覧するエンドポイントをDjango REST Frameworkで生やして、データをエクスポート

はじめに

お返し申す!1 nikkieです。

HerokuでいくつかのDjangoアプリを動かしていましたが、この週末はお片付け。
Herokuのアプリを消す前に、DBに格納されたデータをエクスポートします(アプリ自体はもう使っていないのでデータを保管したらアプリは消します)。
11/26のPython mini Hack-a-thonで取り組みました2

※筆者はDjangoを実務でバリバリ使っているわけではなく、今回の実装はDjangoアプリの練習(ごく小規模なアプリでうまくいった例)といった趣です。
このブログの情報を実務で利用する場合には、紹介している書籍やドキュメントまで確認されることを強くおすすめします。

目次

背景:PyCon JPスタッフ活動で作ったDjangoアプリ

無料でアプリがデプロイできる(さらにDBも使える)Herokuには大変お世話になってきました。
そして、いよいよ無料枠の終了が迫っています(11/28に終了)。

Herokuにデプロイしたアプリには、過去のPyCon JPスタッフ活動で実装したDjangoアプリ3もあります。
PyCon JPはトークを公募していて、集まったプロポーザル(「私はこういうトークをしたい」という文書)をレビュアー(スタッフ & ボランティア)がレビューし、採択します4
レビュアーがプロポーザルをレビューするためのアプリ」を2020、2021で作りました5

扱うデータ

  • プロポーザル(2020、2021で使ったサービス sessionize6 から読み込み)
    • レビューアプリでは、プロポーザルは読み取りだけです
  • レビュー
    • レビュアーが書き込みます(レビュアーは何件もレビューを書けるが、1つのレビューは1人のレビュアーだけに対応する)
    • プロポーザルには何件レビューがあってもよいが、1つのレビューは1つのプロポーザルだけに対応する
    • 1つのプロポーザルをN人以上で目を通すようにレビューを進めます

参考までにReviewモデルの定義7です。

class Review(models.Model):
    class ReviewScore(models.IntegerChoices):
        YES = 5, "YES😃"
        MAYBE = 3, "MAYBE😐"
        NO = 1, "NO😫"

    reviewer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        verbose_name="レビュアー",
    )
    proposal = models.ForeignKey(
        Proposal,
        on_delete=models.PROTECT,
        related_name="reviews",
        verbose_name="レビュー対象のプロポーザル",
    )
    score = models.PositiveIntegerField("レビュースコア", choices=ReviewScore.choices)
    comment = models.TextField("レビューコメント(NOの場合は必須)", blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

数十人のレビュアーで使い、データ量としてはプロポーザルが100件程度、レビューが500〜600件程度です。

Django REST Frameworkでデータを一覧するエンドポイントを生やす

データのエクスポートの方法はWeb API以外にもあると思いますが、今回はWeb APIを選びました。
これは「DRFDjango REST Framework)を触ってみたかった」という理由によります。
アプリ自体を別の環境に移したいわけではなく、人が読める形でデータをファイルに保存しさえすれば、今回のエクスポートは達成されます。

DRFは2021のアプリ開発で初めて触りましたが、akiyokoさんの『現場で使える Django REST Framework の教科書』(以降、この記事では「DRFの教科書」)にめちゃめちゃ助けていただきました。
日本語で読めてぱっと完全に理解できるリソースとして、大変貴重と思います。

レビュー一覧用のシリアライザ

エクスポートしたいデータ(HerokuのDBにだけ入っているデータ)はレビューだけです(プロポーザルはsessionizeに入っています)。
「誰がどのプロポーザルをこれこれの理由で何点とレビューした」という情報が取り出せれば、今回の目的には十分でしょう。
これをSerializerとして実装しました。

class ReviewSerializer(serializers.ModelSerializer):
    reviewer_name = serializers.ReadOnlyField(source="reviewer.username")
    proposal_id = serializers.ReadOnlyField(source="proposal.sessionize_id")

    class Meta:
        model = Review
        fields = [
            "reviewer_name",
            "proposal_id",
            "score",
            "comment",
            "created_at",
            "updated_at",
        ]

実装する上では、『DRFの教科書』の11.7「関連先のモデルまで含めた情報を取得したい」が役立ちました。
ReadOnlyFieldsource引数!

レビュー一覧用のビュー(ページネーション付き!)

レビューは一度に全件返すのではなく、ページネーションして返したいです。
DRFの教科書』では、取得(一覧)APIは4.2、ページネーションは11.5で扱われています。
settings.pyによる全エンドポイントへの設定ではなく、個別のエンドポイントに設定する方法が知りたかったので、教科書の案内に従ってDRFのドキュメントを参照しました。

今回はPageNumberPaginationを使います。

  1. 継承したクラスで、クラス変数を使ってページサイズを指定します
  2. generics.ListAPIViewを継承したビューのクラスで、クラス変数pagination_classに1のクラスを指定します
  3. ビューのクラスにはあと2つクラス変数querysetserializer_classも指定します
class StandardResultsSetPagination(PageNumberPagination):
    page_size = 100


class ReviewListAPIView(generics.ListAPIView):
    queryset = Review.objects.select_related("reviewer", "proposal").order_by(
        "pk"
    )
    serializer_class = ReviewSerializer
    pagination_class = StandardResultsSetPagination

これでページネーションされました!
DRFの教科書』から離れたので実装しきれるか不安でしたが、DRFに用意されたクラスを使い、クラス変数を設定するだけでページネーションが実現できています!

Adminだけレビューを一覧できる

レビューアプリにはAdminがプロポーザルを登録できるエンドポイントがありました。
認証してトークンを取得し、それを使ってプロポーザルを登録します。
DRFの教科書』7.4で解説されていて、djoserを使って実現しています。

この実装にならい、レビューの一覧エンドポイントも使えるのはAdminだけとしました。

class ReviewListAPIView(generics.ListAPIView):
    permission_classes = [IsAdminUser]  # 追加
    # 変更はないので省略

ページネーションの仕組み

私にとってDRFブラックボックス(ラップされすぎていて自分のDjango力でデバッグできるか不安)だったので、ページネーションを最初はAPIViewで実装しようとしました。

If you're using a regular APIView, you'll need to call into the pagination API yourself to ensure you return a paginated response. See the source code for the mixins.ListModelMixin and generics.GenericAPIView classes for an example. (上で紹介したDRFのドキュメントより)

mixins.ListModelMixinの実装がこちら8

class ListModelMixin:
    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

filter_querysetReviewへのクエリセットをベタ書きし、get_serializerは先に定義したReviewSerializerを使えばよさそうです。

これを真似し、上記の点だけ変えてビュークラスのgetメソッドを実装すると、paginate_querysetget_paginated_responseメソッドがないことでAttributeErrorとなりました。

そこでgenerics.GenericAPIViewの実装を見ます9

class GenericAPIView(views.APIView):
    pagination_class = api_settings.DEFAULT_PAGINATION_CLASS

    @property
    def paginator(self):
        if not hasattr(self, '_paginator'):
            if self.pagination_class is None:
                self._paginator = None
            else:
                self._paginator = self.pagination_class()
        return self._paginator

    def paginate_queryset(self, queryset):
        if self.paginator is None:
            return None
        return self.paginator.paginate_queryset(queryset, self.request, view=self)

    def get_paginated_response(self, data):
        assert self.paginator is not None
        return self.paginator.get_paginated_response(data)

paginatorプロパティでpagination_classインスタンス化されます。
今回の例では(上で定義した)StandardResultsSetPaginationインスタンス化します。
ページネーション処理はStandardResultsSetPaginationインスタンスに任せる(paginate_querysetget_paginated_response)という実装で、これは委譲していますね。

以下が当初の実装(APIViewを継承したビュークラス)です10

class ReviewListAPIView(views.APIView):
    permission_classes = [IsAdminUser]
    pagination_class = StandardResultsSetPagination

    @property
    def paginator(self):
        if not hasattr(self, "_paginator"):
            self._paginator = self.pagination_class()
        return self._paginator

    def get(self, request, *args, **kwargs):
        reviews = Review.objects.select_related(
            "reviewer", "proposal"
        ).order_by("pk")
        page = self.paginator.paginate_queryset(reviews, request, view=self)
        if page is not None:
            serializer = ReviewSerializer(page, many=True)
            return self.paginator.get_paginated_response(serializer.data)

        serializer = ReviewSerializer(reviews, many=True)
        return Response(serializer.data)

この実装で動かしたあと、ListAPIViewの実装を見たら11ListModelMixinGenericAPIViewから多重継承されていることが分かりました。

class ListAPIView(mixins.ListModelMixin,
                  GenericAPIView):

これを見て「ListAPIViewを継承したら簡潔に書けるな」と腑に落ち、上で紹介した実装となりました。

Web API以外のやり方:エクスポートコマンド追加

python manage.py dump_reviewsのようなコマンドを追加してファイルに掃き出してエクスポートする方法も考えられます。
heroku run bash12でアプリケーションに接続し、コマンドを実行させてファイルは作成できました。
ですが、Heroku側にできたファイルをローカルに取る方法が限られます。

Herokuにはgit pushでデプロイできるので、「Heroku側でコミットを作れるのではないか(それをpullしよう)」と考えました。
ところが、heroku run bashしてからgitコマンドを叩くと、「カレントディレクトリはGitリポジトリではない」旨のエラーメッセージが表示されます(ファイルは同期していますが、Gitリポジトリではないようです)。
コミットを作ってpullする案はうまく動きませんでした13

今回はエクスポートしたファイルが数百KBだったので、catでターミナルに書き出し、それをコピペして移しました(2020のアプリの方で実施)。
2通りやってみて、Web APIのやり方がしっくり来ています。

終わりに

Heroku無料枠終了に伴い、自作DjangoアプリにWeb APIの口を追加して、HerokuのDBのデータをエクスポートしたことを共有しました。
generics.ListAPIViewの継承により、いくつかのクラス変数を設定するだけページネーション付きの一覧APIが実装できます!

DRFソースコードを初めて読んでみましたが、「意外と読めるぞ」という感覚でした。
Djangoアプリと思えばいいんですね、怖がりすぎてました。

今回取り上げたアプリの実装はこちらにアーカイブしています。

なお、最近のプロポーザルシステムはpretalx14が主流と思われます(2022のEuroPythonやPyCon JPで使用)。
こちらもDjangoアプリみたいなので、ソース読んでみたいなと思っています。

最後に、Thank you, Heroku Free Plan❤️15
いままでクソお世話になりました。
ありがとうございました!
風邪引くなよ!!


  1. 参加者のsoogieさんはHerokuの機能を使ってPostgres DBのバックアップを取得されていました(スライド3)。Herokuの機能でもサポートしているんですね!
  2. 過去に登壇もしています。 https://pycon.jp/2020/timetable/?id=203919
  3. 2022の例です。PyCon JP Blog: PyCon JP 2022 プロポーザルレビュアー募集のお知らせ
  4. 私のアツいスタッフ活動!技術で(も)支えたPyCon JP 2021 #pyconjp - nikkie-ftnextの日記 でも紹介した「プロポーザルレビューアプリ」です
  5. PyCon JP Blog: PyCon JP 2020を支えたツール sessionize について | Thank you, sessionize! Part 1/2
  6. https://github.com/pyconjp/pycon.jp.2021.review/blob/352ec3f2ac5d9473ed723882597287f23f4e79ab/reviewsite/review/models.py#L124-L147
  7. https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/mixins.py#L33-L46
  8. https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/generics.py#L165-L171
  9. https://github.com/pyconjp/pycon.jp.2021.review/commit/ec7999f001b9f6cde7ff3cfb428c637915b10c01
  10. https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/generics.py#L193-L199
  11. heroku runコマンド https://devcenter.heroku.com/articles/heroku-cli-commands#heroku-run (記事を書いていて気付きましたが-r指定は試せてはいないですねー)
  12. また、heroku run bashでファイルを作った後、もう一度heroku runすると、作られたファイルはありませんでした。heroku rungit pushした状態で始まるようです
  13. 私は「プレトークス」と呼んでいます。プロポーザル=トークの前(プレ)で、トークになるまで扱えるので洒落た名前だと思ってます。
  14. 新しい低コストプランはこちらにあるみたいです。Introducing Our New Low-Cost Plans | Heroku