こんにちは、ideです。
SQLを使って手動でデータを変更する場合、どんな単純な作業でも手順ミスは起こりえるかなと思います。 そこで今回は、Django上で選択した内容に応じてSQLを作成して実行し、結果を実行ログとして残すまでの 一連の流れをまとめました。 UIはDjangoに統合する形にしています。
今回は新規権限をリリースしたあと、基準となる権限が付与されているアカウント全てに、新規権限を一括付与する といったユースケースを想定しています。
前提
この記事で実装するもの
権限付与のフォーム
- 管理画面と同じ見た目で表示(ヘッダー/フッター/左メニュー/ログイン情報)
- 「基準権限 → 新規権限」を選んで実行
- 管理画面の見た目を通常のDjangoと同じにする
- ビューを管理画面として設定(
admin_view
) - 管理画面のテンプレートを継承(
base_site.html
) - 共通コンテキストでサイドバー等を表示(
each_context
)
- ビューを管理画面として設定(
生SQLの実行
- 権限付与のフォームで選択した内容を反映できるよう、バインド変数を使用
- 権限追加と実行履歴の追加を1つのトランザクションで実行
実行ログ
- いつ/誰が/どの条件で/何件付与したかを保存し、一覧と詳細で確認
左メニューへの追加
- 左メニューに「権限一括付与」を追加し、クリックでSQLの実行画面へ遷移
実際の画面キャプチャ
サイドバーにリンクが存在している
- 左サイドバーに「権限一括付与」が表示されていること
- 左サイドバーに「権限一括付与」が表示されていること
実行画面がDjango管理UIと同じ見た目
- ヘッダー/フッター/左サイドバー/ログイン情報のDjangoの通常の表示と同じになること
- ヘッダー/フッター/左サイドバー/ログイン情報のDjangoの通常の表示と同じになること
実行後に何件実行されたか
- 成功メッセージ(緑のアラート)に「○件のアカウントに新権限を付与しました。」と件数が出ること
- 成功メッセージ(緑のアラート)に「○件のアカウントに新権限を付与しました。」と件数が出ること
実行ログに反映されている
- 「権限実行ログ」の一覧が表示され、実行日時・実施者・条件・追加件数が表示されること
- 「権限実行ログ」の一覧が表示され、実行日時・実施者・条件・追加件数が表示されること
モデル(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