3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Django でデータをプロジェクト的単位に分ける

Last updated at Posted at 2019-10-31

前置き

記事の内容

  • タスク管理ツールに見られるような複数の "プロジェクト" を作成し、各プロジェクト内でアイテムを管理するというのを、Django で実装しました。
  • プロジェクトごとにアクセスできるユーザも変わります。

作っているもの

  • 演劇の稽古プランを補助するツールです。
  • タイトルで言っている「プロジェクト的単位」は、ここでは「公演」になります。
  • 各公演ごとに、稽古日程、役者、登場人物、シーンなどを管理します。
  • -> GitHub のリポジトリ (Python 3.8.1, Django 3.0.3)

記事のコンセプト

  • やりたいことのサンプルがネットで見つからなかったので、自分で考えて実装しました。それを共有します。
  • もっと良いやり方をご存知の方は、教えて頂けると嬉しいです。

基本的な部品

まず "公演" と "公演ユーザ" を作って、ログイン中のユーザが関わっている公演の一覧を出すところまでです。

公演 (プロジェクト的単位) のモデル

フィールドは name だけです。

production/models.py
from django.db import models

class Production(models.Model):
    name = models.CharField('公演名', max_length=50)

    def __str__(self):
        return self.name

公演ユーザのモデル

公演ごとのユーザです。
ForeignKey で公演とユーザ (Django が管理しているユーザ) を参照し、権限のためのフィールドを設定してあります。
AUTH_USER_MODEL についてはこちらを御覧ください。
この例では、公演またはユーザが削除されたら公演ユーザも削除されます。

production/models.py
from django.conf import settings

class ProdUser(models.Model):
    '''公演ごとのユーザと権限
    '''
    production = models.ForeignKey(Production, verbose_name='公演',
        on_delete=models.CASCADE)
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
        verbose_name='ユーザ', on_delete=models.CASCADE)
    is_owner = models.BooleanField('所有権', default=False)
    is_editor = models.BooleanField('編集権', default=False)

公演一覧のビュー

ログイン中のユーザが関わっている公演の一覧です。
user フィールドがログイン中のユーザであるような "公演ユーザ" を取得します。
self.request.user から逆参照しても取得できるかも知れません。

公演名と "公演 ID" が間接的に使えるので、それで公演のリストを表示することにします。つまり、公演ユーザのデータがあれば、公演そのものを取得する必要はありません。

production/views.py
from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import ProdUser

class ProdList(LoginRequiredMixin, ListView):
    '''Production のリストビュー
    
    ログインユーザの公演のみ表示するため、モデルは ProdUser
    '''
    model = ProdUser
    template_name = 'production/production_list.html'
    
    def get_queryset(self):
        '''リストに表示するレコードをフィルタする
        '''
        # 自分である ProdUser を取得する
        prod_users = ProdUser.objects.filter(user=self.request.user)
        return prod_users

適当な URL パターンとテンプレートを書いて、Admin 画面で "公演" と "公演ユーザ" を追加してログインすると、その人がユーザになっている公演の一覧が出るという寸法です。

公演一覧.png

-> テンプレートはこんな感じ

新規公演を作る

タスク管理ツールで例えると、新規プロジェクトを作るようなことです。

公演を追加するビュー

上の "公演一覧" のテンプレートを見ると、"新規作成" というリンクがあります。
これを押して以下のビューを呼び出すようにしています。パラメタは要りません。

production/views.py
from django.views.generic.edit import CreateView
from .models import Production

class ProdCreate(LoginRequiredMixin, CreateView):
    '''Production の追加ビュー
    '''
    model = Production
    fields = ('name',) # 編集項目は name だけです
    template_name_suffix = '_create'
    success_url = reverse_lazy('production:prod_list') # 追加したら一覧へ
    
    def form_valid(self, form):
        '''バリデーションを通った時
        '''
        # 保存したレコードを取得する
        new_prod = form.save(commit=True)
        
        # 自分を owner として公演ユーザに追加する
        prod_user = ProdUser(production=new_prod, user=self.request.user,
            is_owner=True)
        prod_user.save()
        
        messages.success(self.request, str(new_prod) + " を作成しました。")
        return super().form_valid(form)
    
    def form_invalid(self, form):
        '''追加に失敗した時
        '''
        messages.warning(self.request, "作成できませんでした。")
        return super().form_invalid(form)

フィールドは name (公演名) だけなのでページは簡素ですが、ちゃんとログイン中のユーザが "公演ユーザ" になって、所有権も与えられます。

新規公演.png

-> テンプレートはこんな感じ

これで、ログイン中のユーザが自分の "公演" を作れるようになりました。

各公演のトップビュー

公演一覧で、各公演をクリックするとその公演のトップビューに遷移するようにします。
ここからは公演ごとにそれぞれ違う世界になります。

上記の公演一覧のテンプレートを見ると、公演名の部分がその "公演" のトップビューを開くリンクになっています。

production/templates/production/production_list.html
    <tr>
        <td>
            <a href="{% url 'rehearsal:rhsl_top' prod_id=item.production.id %}">
                {{ item.production }}</a>
        </td>
        <td>{{ item.is_owner }}</td>
        <td>{{ item.is_editor }}</td>
    </tr>

トップビューを rehearsal というアプリケーションの中に作ったので、このようなリンクになっています。
このリンクにはパラメタとして "公演 ID" が必要です (コード内でのパラメタ名は prod_id)。
表示中の item は "公演ユーザ" なので、"公演 ID" には item.production.id を渡します。

ビューはシンプルなメニュー画面にしてあります。

rehearsal/views/views.py
from django.views.generic import TemplateView
from django.core.exceptions import PermissionDenied
from .view_func import *

class RhslTop(LoginRequiredMixin, TemplateView):
    '''Rehearsal のトップページ
    '''
    template_name = 'rehearsal/top.html'
    
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けたハンドラ
        '''
        # アクセス情報から公演ユーザを取得しアクセス権を検査する
        prod_user = accessing_prod_user(self)
        if not prod_user:
            raise PermissionDenied
        
        # production を view の属性として持っておく
        self.production = prod_user.production
        
        return super().get(request, *args, **kwargs)

production ("公演" オブジェクト) を view の属性として持っておくのは、テンプレートから参照するためです。

accessing_prod_user() は、view_func.py という別モジュールで定義している関数で、ログイン中のユーザがその公演のユーザであれば、その "公演ユーザ" オブジェクトを返します。

rehearsal/views/view_func.py
from production.models import ProdUser

def accessing_prod_user(view, prod_id=None):
    '''アクセス情報から対応する ProdUser を取得する
    
    Parameters
    ----------
    view : View
        アクセス情報の取得元の View
    prod_id : int
        URLconf に prod_id が無い場合に指定する
    '''
    if not prod_id:
        prod_id=view.kwargs['prod_id']
    prod_users = ProdUser.objects.filter(
        production__pk=prod_id, user=view.request.user)
    if len(prod_users) < 1:
        return None
    return prod_users[0]

ログイン中のユーザが公演ユーザでなければ None を返しますので、これでアクセス制御ができます。

稽古トップ.png

-> テンプレートはこんな感じ

ただのリンクリストですが、すべてのリンクに prod_id パラメタが付いています。

公演に属するデータ

"公演" という単位でデータを分ける下地ができましたので、各公演に属するデータを扱えるようにします。

役者のモデル

公演に関わっている役者のデータです。
公演に属するデータには production という ForeignKey を持たせ、どの公演に属するか決めるようにします。

rehearsal/models.py
class Actor(models.Model):
    '''役者
    '''
    production = models.ForeignKey(Production, verbose_name='公演',
        on_delete=models.CASCADE)
    name = models.CharField('名前', max_length=50)
    short_name = models.CharField('短縮名', max_length=5, blank=True)
    
    class Meta:
        verbose_name = verbose_name_plural = '役者'
    
    def __str__(self):
        return self.name
    
    def get_short_name(self):
        return self.short_name or self.name[:3]

short_name (短縮名) は、表形式で表示する時などに使います。

役者一覧のビュー

公演のトップビューから「役者一覧」をクリックして遷移するビューです。
もちろん、その公演の役者だけを表示しますし、その公演の "公演ユーザ" 以外から見えてはだめです。

役者一覧のビューの前に、公演のトップビューで役者一覧にリンクしている所を見てみましょう。

rehearsal/templates/rehearsal/top.html
<li><a href="{% url 'rehearsal:actr_list' prod_id=view.production.id %}">
    役者一覧</a></li>

production ("公演" オブジェクト) を view の属性として持っておいたので、view.production.id という形で "公演 ID" を取得できます。
それを prod_id パラメタに渡すことで、「その公演の」役者一覧に遷移するのです。

では、役者一覧のビューです。

rehearsal/views/views.py
class ActrList(ProdBaseListView):
    '''Actor のリストビュー
    '''
    model = Actor
    
    def get_queryset(self):
        '''リストに表示するレコードをフィルタする
        '''
        prod_id=self.kwargs['prod_id']
        return Actor.objects.filter(production__pk=prod_id)\
            .order_by('name')

継承している ProdBaseListView は、公演に属するデータの一覧を表示するための抽象クラスです。
他にもこういったビューを作ることになるので、アクセス制御などは抽象クラスにまとめました。
これも見てみましょう。

rehearsal/views/views.py
class ProdBaseListView(LoginRequiredMixin, ListView):
    '''アクセス権を検査する ListView の Base class
    '''
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # アクセス情報から公演ユーザを取得しアクセス権を検査する
        prod_user = accessing_prod_user(self)
        if not prod_user:
            raise PermissionDenied
        
        # アクセス中の ProdUser を view の属性として持っておく
        # テンプレートで追加ボタンの有無を決めるため
        self.prod_user = prod_user
        
        return super().get(request, *args, **kwargs)
    
    def get_context_data(self, **kwargs):
        '''テンプレートに渡すパラメタを改変する
        '''
        context = super().get_context_data(**kwargs)
        
        # 戻るボタン, 追加ボタン用の prod_id をセット
        context['prod_id'] = self.kwargs['prod_id']
        
        return context

公演 ID (prod_id) を context に入れているのは、テンプレートから簡単に参照できるようにです。
context に入れなくても view.prod_user.production.id とすれば参照できますが、長いので。

-> テンプレートはこんな感じ

Admin 画面で "役者" をいくつか追加して、役者一覧を表示してみましょう。

役者一覧.png

役者を追加するビュー

上の "役者一覧" のテンプレートを見ると、"追加" というリンクがあります。
そして、公演の所有権または編集権を持ったユーザにだけ表示するようになっています。
もちろん、リンクが見えないユーザもその URL を直接ブラウザで開くことは出来るので、役者を追加するビューにもアクセス制御が必要です。

ここでも、上記の ProdBaseListView と同様の抽象クラスを作っています。
先に抽象クラスの方を見てみましょう。

rehearsal/views/views.py
class ProdBaseCreateView(LoginRequiredMixin, CreateView):
    '''アクセス権を検査する CreateView の Base class
    '''
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # 編集権を検査してアクセス中の公演ユーザを取得する
        prod_user = test_edit_permission(self)
        
        # production を view の属性として持っておく
        # テンプレートで固定要素として表示するため
        self.production = prod_user.production
        
        return super().get(request, *args, **kwargs)
    
    def post(self, request, *args, **kwargs):
        '''保存時のリクエストを受けるハンドラ
        '''
        # 編集権を検査してアクセス中の公演ユーザを取得する
        prod_user = test_edit_permission(self)
        
        # production を view の属性として持っておく
        # 保存時にインスタンスにセットするため
        self.production = prod_user.production
        
        return super().post(request, *args, **kwargs)
    
    def form_valid(self, form):
        '''バリデーションを通った時
        '''
        # 追加しようとするレコードの production をセット
        instance = form.save(commit=False)
        instance.production = self.production
        
        messages.success(self.request, str(instance) + " を追加しました。")
        return super().form_valid(form)
    
    def form_invalid(self, form):
        '''追加に失敗した時
        '''
        messages.warning(self.request, "追加できませんでした。")
        return super().form_invalid(form)

test_edit_permission() は、view_func.py で定義している関数で、ログイン中のユーザがその公演の所有権または編集権を持っていれば、その "公演ユーザ" を返します。

def test_edit_permission(view, prod_id=None):
    '''編集権を検査する
    
    Returns
    -------
    prod_user : ProdUser
        編集権を持っているアクセス中の ProdUser
    '''
    # アクセス情報から公演ユーザを取得する
    prod_user = accessing_prod_user(view, prod_id=prod_id)
    if not prod_user:
        raise PermissionDenied
    
    # 所有権または編集権を持っていなければアクセス拒否
    if not (prod_user.is_owner or prod_user.is_editor):
        raise PermissionDenied
    
    return prod_user

ビューを表示する時 (get メソッド) と「追加」ボタンを押して追加の処理をする時 (post メソッド) の、両方でアクセス権をチェックしています。

また、この抽象クラスでは、「公演に属するデータを追加する時は production フィールドにその公演をセットする」という共通処理も実装してあります。

では、役者を追加するビューです。

rehearsal/views/views.py
class ActrCreate(ProdBaseCreateView):
    '''Actor の追加ビュー
    '''
    model = Actor
    fields = ('name', 'short_name')
    
    def get_success_url(self):
        '''追加に成功した時の遷移先を動的に与える
        '''
        prod_id = self.production.id
        url = reverse_lazy('rehearsal:actr_list', kwargs={'prod_id': prod_id})
        return url

追加した後は、「その公演の」役者一覧に戻るようになっています。

役者の追加.png

-> テンプレートはこんな感じ

※このテンプレートは、新しく役者を追加する時と、すでにある役者を編集する時の、両方に使えるようになっています。

まとめ

  • 演劇の稽古プランを補助するツールで、複数の "公演" を作って、公演ごとにデータを分けるようにしました。
  • "公演ユーザ" というモデルを作って、ユーザ (Django が管理しているユーザ) ごとにアクセス制御できるようにしました。
  • 公演に属するデータ (役者など) には、production という ForeignKey フィールドを持たせ、公演のメニューからアクセスする時は、それでフィルタするようにしました。
  • 追加や変更などデータを書き込むビューへのアクセスは、表示時と書き込み時の両方で編集権をチェックするようにしました。
  • 公演に属するデータを新しく追加する時は、production フィールドに自動的に公演をセットするようにしました。
つづきを書きました
3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?