見出し画像

Django_フォルダを再帰的に論理削除する #387

Djangoでアプリケーションにおいて、レポート一覧をフォルダに分ける機能を実装しようとしています。今回はフォルダデータを持つモデルと、その削除方法について実装例をご紹介します。

アプリケーションの要件は以下です。

  • フォルダには複数のレポートを格納

  • フォルダの中にフォルダを作成可能

  • フォルダの削除に関する要件は4つ

    • 削除は全て論理削除で行う(物理削除ではない)

    • 削除時に指定するのは1フォルダのみ(複数一括選択はしない)

    • 指定したフォルダの子孫フォルダも全て削除

    • 削除されたフォルダ内のレポートは削除せず、全てトップ階層に移動


まずレポートデータのモデルを見ていきます。parent_folderに親フォルダの情報を持っています。

class Report(models.Model):
  name = models.CharField(max_length=255)
  content = models.TextField(null=True, verbose_name='内容')
  created_by = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
  deleted_at = models.DateTimeField(null=True)
  parent_folder = models.ForeignKey('CampaignFolder', on_delete=models.SET_NULL, db_constraint=False, null=True, related_name='reports', verbose_name='親フォルダID')

続いて肝心のフォルダデータのモデルです。

class ReportFolder(models.Model):
  name = models.CharField(max_length=255, verbose_name='レポート名')
  parent_folder = models.ForeignKey('ReportFolder', on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name='child_folders', verbose_name='親フォルダID')
  updated_by = models.ForeignKey("User", on_delete=models.DO_NOTHING, null=True, related_name="+", verbose_name='更新者ID')
  deleted_at = models.DateTimeField(null=True, verbose_name='削除日時')


  def _release_related_reports(self, user: User):
    reports = self.reports.filter(deleted_at__isnull=True)
    reports.update(updated_by=user, parent_folder=None)

  def delete(self, *args, **kwargs):
    user = kwargs['user']
    self._release_related_reports(user)
    ReprotFolder.objects.filter(id=self.id).update(deleted_at=timezone.now(), updated_by=user)
    # すべての子フォルダに対して論理削除を再帰的に適用
    for folder in self.child_folders.filter(deleted_at__isnull=True):
      folder._release_related_reports(user)
      folder.delete(user=user)

以下のコードで削除を実行できます(Userモデルは解説を省略しています)。

user = User.objects.get(id=2)
CampaignFolder.objects.get(id=1).delete(user=user)


modelのdeleteをオーバーライド

1つ目のポイントはどのdelete()をオーバーライドするかです。modelのレコードを操作する際、実はdelete()は2種類あります。

・modelオブジェクトのdelete()
・QuerySetのdelete()

前者はmodelの各オブジェクトが持っているメソッドで、後者は複数のデータを一括で操作する場合などに使われるQuerySetのメソッドです。

もちろんdeleteに限らず他のCRUD操作も上記のように分かれています。

今回はアプリケーションの要件で「削除時に指定するのは1フォルダのみ(複数一括選択はしない)」があるので、modelオブジェクトのdelete()を使用します。

最終的にdeleteするためのコードも

CampaignFolder.objects.get(id=1).delete(user=user)

とget()を使用してmodelオブジェクトを取得して削除しています。

また、get()とfilter()は似ていますが、get()は条件に一致するレコードを直接返す一方、filter()はQuerySetを返します。get()では条件に一致するレコードがなければエラーになるのに対し、filter()ではエラーが起きないのは、filter()では空のQuerySetが返っているためです。


子フォルダ取得して子フォルダのdelete()も呼び出す

この部分で再帰的なdelete()を可能にしています。以下のように、delete()の中でdelete()を呼び出しています。

  def delete(self, *args, **kwargs):
    user = kwargs['user']
    self._release_related_reports(user)
    ReprotFolder.objects.filter(id=self.id).update(deleted_at=timezone.now(), updated_by=user)
    # すべての子フォルダに対して論理削除を再帰的に適用
    for folder in self.child_folders.filter(deleted_at__isnull=True):
      folder._release_related_reports(user)
      folder.delete(user=user)

modelのparent_folderは自己参照型のForeignKeyになっていて、child_foldersという名前で親フォルダから逆引き可能にしています。

class ReportFolder(models.Model):
  name = models.CharField(max_length=255, verbose_name='レポート名')
  parent_folder = models.ForeignKey('ReportFolder', on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name='child_folders', verbose_name='親フォルダID')
  updated_by = models.ForeignKey("User", on_delete=models.DO_NOTHING, null=True, related_name="+", verbose_name='更新者ID')
  deleted_at = models.DateTimeField(null=True, verbose_name='削除日時')

つまりdelete()のself.child_folders.filter(deleted_at__isnull=True)で、論理削除されていない全ての子フォルダを取得して、その子フォルダ1つ1つで(子フォルダの)delete()を呼び出しています。

ここで呼ばれているdelete()はもちろん親フォルダのdelete()と同じなので、同じように子フォルダは孫フォルダを全て取得して、その孫フォルダ1つ1つで(孫フォルダの)delete()を呼び出します。

前述の通りfilterは該当するレコードがなければ空のQuerySetを返すので、削除対象のフォルダがなければfor文は動かず、次の処理に進みます。

このようにして親フォルダに紐づく全ての子孫フォルダに対して論理削除を実行することが可能です。


レポートはトップ階層に戻す

アプリケーションの要件で「削除されたフォルダ内のレポートは削除せず、全てトップ階層に移動」がありました。

先ほどのdelete()処理の中に入れ込んであったself._release_related_reports(user)というメソッドでそれを実現しています。

トップ階層に戻すといっても、該当レポートのparent_folderをNoneにするだけです。つまり親フォルダを持たないレポートはトップ階層に所属する、という実装にしています。

  def _release_related_reports(self, user: User):
    reports = self.reports.filter(deleted_at__isnull=True)
    reports.update(updated_by=user, parent_folder=None)

selfには処理中のReportFolderのレコードが入ります。ここでもForeginKeyの逆引きを使って、この親フォルダに属するレポートを参照しています。

Reportモデルは以下でした。parent_folderrelated_name='reports'を設定しており、親から逆引きできるようにしています。

class Report(models.Model):
  name = models.CharField(max_length=255)
  content = models.TextField(null=True, verbose_name='内容')
  created_by = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+")
  deleted_at = models.DateTimeField(null=True)
  parent_folder = models.ForeignKey('CampaignFolder', on_delete=models.SET_NULL, db_constraint=False, null=True, related_name='reports', verbose_name='親フォルダID')



ここまでお読みいただきありがとうございました!

この記事が気に入ったらサポートをしてみませんか?