iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Djangoで独自SQLを実行し、実行履歴を残す

こんにちは、ideです。

SQLを使って手動でデータを変更する場合、どんな単純な作業でも手順ミスは起こりえるかなと思います。 そこで今回は、Django上で選択した内容に応じてSQLを作成して実行し、結果を実行ログとして残すまでの 一連の流れをまとめました。 UIはDjangoに統合する形にしています。

今回は新規権限をリリースしたあと、基準となる権限が付与されているアカウント全てに、新規権限を一括付与する といったユースケースを想定しています。


前提

  • Python 3.13 / Django 5.2(管理ユーザー作成済み)

この記事で実装するもの

  1. 権限付与のフォーム

    • 管理画面と同じ見た目で表示(ヘッダー/フッター/左メニュー/ログイン情報)
    • 「基準権限 → 新規権限」を選んで実行
    • 管理画面の見た目を通常のDjangoと同じにする
      • ビューを管理画面として設定(admin_view
      • 管理画面のテンプレートを継承(base_site.html
      • 共通コンテキストでサイドバー等を表示(each_context
  2. SQLの実行

    • 権限付与のフォームで選択した内容を反映できるよう、バインド変数を使用
    • 権限追加と実行履歴の追加を1つのトランザクションで実行
  3. 実行ログ

    • いつ/誰が/どの条件で/何件付与したかを保存し、一覧と詳細で確認
  4. 左メニューへの追加

    • 左メニューに「権限一括付与」を追加し、クリックでSQLの実行画面へ遷移

実際の画面キャプチャ

  1. サイドバーにリンクが存在している

    • 左サイドバーに「権限一括付与」が表示されていること
  2. 実行画面がDjango管理UIと同じ見た目

    • ヘッダー/フッター/左サイドバー/ログイン情報のDjangoの通常の表示と同じになること
  3. 実行後に何件実行されたか

    • 成功メッセージ(緑のアラート)に「○件のアカウントに新権限を付与しました。」と件数が出ること
  4. 実行ログに反映されている

    • 「権限実行ログ」の一覧が表示され、実行日時・実施者・条件・追加件数が表示されること


モデル(permissions/models.py

from django.conf import settings
from django.db import models


# 権限
class Permission(models.Model):
    code = models.CharField(max_length=100, unique=True)
    name = models.CharField(max_length=255)
    def __str__(self): return self.name

# アカウント
class Account(models.Model):
    email = models.EmailField(unique=True)
    def __str__(self): return self.email


# アカウント権限
class AccountPermission(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

# 実行ログ
class PermissionGrantLog(models.Model):
    executed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    base_permission = models.ForeignKey(Permission, on_delete=models.PROTECT, related_name="+")
    new_permission  = models.ForeignKey(Permission, on_delete=models.PROTECT, related_name="+")
    executed_at = models.DateTimeField(auto_now_add=True)
    added_count = models.IntegerField()

    class Meta:
        verbose_name = "権限実行ログ"
        verbose_name_plural = "権限実行ログ"


class PermissionGrantTool(PermissionGrantLog):
    """
    Admin の左メニューに「権限一括付与」を追加するための ProxyModel。
    新規テーブルは作られず、PermissionGrantLog と同じ実テーブルを参照する。
    """
    class Meta:
        proxy = True
        verbose_name = "権限一括付与"
        verbose_name_plural = "権限一括付与"

補足:管理画面の左メニューに表示する方法と仕組み

左メニューとDBテーブルの関係

  • 左メニューは 「登録済みの ModelAdmin の数」だけ表示される。
    -> DBテーブルと1:1ではない -> 同じモデルは1回しか登録できないため、同じテーブルで複数メニューが必要なら Proxy モデルで作成する

Proxy モデルでメニューだけ増やす

  • proxy=True を使うと、テーブルを増やさず管理画面に出せる。

権限を設定するフォーム(permissions/forms.py

from django import forms
from .models import Permission

class GrantForm(forms.Form):
    base_permission = forms.ModelChoiceField(queryset=Permission.objects.all(), label="基準権限")
    new_permission  = forms.ModelChoiceField(queryset=Permission.objects.all(), label="新規権限")

権限設定する画面のビュー(permissions/views.py

from django.contrib import admin, messages
from django.template.response import TemplateResponse
from django.shortcuts import redirect
from .forms import GrantForm
from .services import grant_permission_bulk_sql


def grant_permission_admin_view(request):
    # 管理画面と同じUIを出すための共通コンテキスト
    context = admin.site.each_context(request)

    if request.method == "POST":
        form = GrantForm(request.POST)
        if form.is_valid():
            try:
                count = grant_permission_bulk_sql(
                    user=request.user,
                    base_permission=form.cleaned_data["base_permission"],
                    new_permission=form.cleaned_data["new_permission"],
                )
                messages.success(request, f"{count} 件のアカウントに新権限を付与しました。")
                return redirect("admin_grant")
            except Exception as e:
                messages.error(request, f"実行に失敗しました:{e}")
    else:
        form = GrantForm()

    context.update({"form": form, "title": "権限一括付与(生SQL)"})
    return TemplateResponse(request, "admin/permissions/grant.html", context)

UIをDjangoと同じように使う方法

  • ビュー内で admin.site.each_context(request) → 左サイドバー/ヘッダー/ログイン情報をテンプレに表示させる

SQLサービス(permissions/services.py

from django.db import connection, transaction
from django.utils import timezone
from .models import AccountPermission, PermissionGrantLog


def grant_permission_bulk_sql(*, user, base_permission, new_permission) -> int:
    """
    基準権限を持つが new_permission は未付与のアカウントに、
    new_permission を一括付与する(生SQL)。
    実行後は PermissionGrantLog に必ず1件ログを残す(追加件数が0件でも記録)
    """
    ap_table = AccountPermission._meta.db_table
    now = timezone.now()

    with transaction.atomic():
        with connection.cursor() as cur:
            # 重複防止: AND NOT EXISTS
            cur.execute(
                f"""
                INSERT INTO {ap_table} (account_id, permission_id, created_at)
                SELECT ap.account_id, %s, %s
                FROM {ap_table} ap
                WHERE ap.permission_id = %s
                  AND NOT EXISTS (
                    SELECT 1
                    FROM {ap_table} ap2
                    WHERE ap2.account_id = ap.account_id
                      AND ap2.permission_id = %s
                  )
                """,
                [new_permission.id, now, base_permission.id, new_permission.id],
            )
            inserted = cur.rowcount

        PermissionGrantLog.objects.create(
            executed_by=user,
            base_permission=base_permission,
            new_permission=new_permission,
            executed_at=now,
            added_count=int(inserted),
        )
    return int(inserted)
  • ORMでSQLを実装するとなると複雑になるため、1クエリで実行可能にしたかった

安全性(バインド変数)
- 値は %s とパラメータで渡す → SQLインジェクション対策
- テーブル名は Model._meta.db_table から取得 → ハードコード回避

**transaction.atomic() とは - データの挿入と、実行ログの作成を同一トランザクションにまとめ、途中で失敗した場合はロールバック、成功した時はコミットする仕組み


ルーティングの追加(myproject/urls.py

from django.contrib import admin
from django.urls import path
from permissions.views import grant_permission_admin_view

urlpatterns = [
    path("admin/grant/", admin.site.admin_view(grant_permission_admin_view), name="admin_grant"),
    path("admin/", admin.site.urls),
]

admin.site.admin_view を設定しないとサイドバーが出ない。


権限を付与する画面テンプレート(templates/admin/permissions/grant.html

{% extends "admin/base_site.html" %}
{% load i18n static %}

{% block breadcrumbs %}
  <div class="breadcrumbs">
    <a href="{% url 'admin:index' %}">{% trans "Home" %}</a> › ツール
  </div>
{% endblock %}

{% block content %}
  <div id="content-main">
    {% if messages %}
      <ul class="messagelist">
        {% for m in messages %}
          <li class="{{ m.tags }}">{{ m }}</li>
        {% endfor %}
      </ul>
    {% endif %}

    <form id="grant-form" method="post" novalidate>
      {% csrf_token %}
      <fieldset class="module aligned">
        <div class="form-row">
          <label>基準権限:</label> {{ form.base_permission }}
        </div>
        <div class="form-row">
          <label>新規権限:</label> {{ form.new_permission }}
        </div>
      </fieldset>
      <div class="submit-row">
        <button id="grant-btn" type="submit" class="default">実行</button>
      </div>
    </form>
  </div>

  <script>
    const form = document.getElementById('grant-form');
    const btn  = document.getElementById('grant-btn');
    form.addEventListener('submit', function () {
      btn.disabled = true;
      btn.textContent = '処理中...';
    });
  </script>
{% endblock %}

admin/base_site.html 継承で、通常のDjangoと同じUI(ヘッダー/フッター/サイドバー/ログイン情報)になります。


管理画面の表示内容の変更(permissions/admin.py)一覧/詳細(閲覧専用)とサイドバーリン

from django.contrib import admin
from .models import Permission, Account, AccountPermission, PermissionGrantLog, PermissionGrantTool

admin.site.register(Permission)
admin.site.register(Account)
admin.site.register(AccountPermission)


@admin.register(PermissionGrantLog)
class PermissionGrantLogAdmin(admin.ModelAdmin):
    # 一覧:IDリンク → 詳細(閲覧のみ)
    list_display = ("obj_id", "executed_at_jst", "executor", "condition", "added_count_ja")
    list_display_links = ("obj_id",)
    ordering = ("-executed_at",)
    list_filter = ("executed_by", "base_permission", "new_permission")
    search_fields = (
        "executed_by__username", "executed_by__email",
        "base_permission__name", "base_permission__code",
        "new_permission__name", "new_permission__code",
    )

    # 詳細は閲覧専用
    actions = None
    change_form_template = "admin/permissions/permissiongrantlog/change_form.html"

    def has_add_permission(self, request): return False
    def has_change_permission(self, request, obj=None): return False
    def has_delete_permission(self, request, obj=None): return False
    def has_view_permission(self, request, obj=None): return True

    # 一覧・詳細 共通の表示メソッド
    @admin.display(ordering="id", description="ID")
    def obj_id(self, obj): return obj.pk

    @admin.display(ordering="executed_at", description="実行日時")
    def executed_at_jst(self, obj):
        return obj.executed_at.strftime("%Y/%m/%d %H:%M:%S")[f:id:heaven2023:20250817224554p:plain]

    @admin.display(description="実施者")
    def executor(self, obj): return obj.executed_by

    @admin.display(description="対象権限")
    def base_perm_label(self, obj): return obj.base_permission

    @admin.display(description="新規権限")
    def new_perm_label(self, obj): return obj.new_permission

    @admin.display(description="追加件数")
    def added_count_ja(self, obj): return obj.added_count

    @admin.display(description="条件")
    def condition(self, obj): return f"{obj.base_permission} → {obj.new_permission}"


# 左サイドバーに「権限一括付与」を出す(プロキシモデル → 一覧アクセスで /admin/grant/ へ遷移)
@admin.register(PermissionGrantTool)
class PermissionGrantToolAdmin(admin.ModelAdmin):
    def changelist_view(self, request, extra_context=None):
        from django.shortcuts import redirect
        return redirect("admin_grant")

    # サイドバーに常時表示させる(view権限を常に許可)
    def get_model_perms(self, request):
        return {"view": True}

    def has_add_permission(self, request): return False
    def has_change_permission(self, request, obj=None): return False
    def has_delete_permission(self, request, obj=None): return False

サイドバー表示の要点
- プロキシモデルPermissionGrantTool)を Admin に登録 → 左サイドバーに項目が出る。


まとめ

実際にやってみると、Djangoでも生のSQL実行とログ記録を組み込めることが分かりました。 管理画面の見た目もそのまま使えたのでよかったです! 今後、実装することがあれば、今回の内容を参考に進めてみようと思います!

現在弊社ではエンジニアを募集しています!

この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

参考

DjangoAdmin 独自ページの追加方法 | SIOS Tech. Lab

素の SQL 文の実行 | Django documentation | Django