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

# 前置き

[この記事](https://qiita.com/satamame/items/85ef4ac690c064bcc1f9)のつづきです。
「プロジェクト的単位」というのはタスク管理ツールなどで言う "プロジェクト" の事で、ここでは演劇の管理をするので「公演」がそれに当たります。

- -> [GitHub のリポジトリ](https://github.com/satamame/pscweb2) (Python 3.8.1, Django 3.0.3)

### 記事の内容

前回は、「公演」に属するデータの入力インターフェイスについて考えました。
今回は、自分以外のユーザを招待して、「公演」のメンバに加える仕組みについて考えてみたいと思います。

- 使ったテクニックについては[「まとめ」](#まとめ)にまとめてあります。
- ~~Heroku に動くデモがあります。詳しくは [GitHub Pages](https://satamame.github.io/pscweb2/) を御覧ください。~~

### 具体的な目標

- 「公演」の所有者が、特定のアカウントに向けて「招待状」というものを作れるようにします。
- 作った「招待状」を確認したり削除したり出来るようにします。
- 招待されたアカウントでログインすると「招待状」が表示されるようにします。
- 受け取った「招待状」から「公演」に参加したり、受け取った「招待状」を削除したり出来るようにします。
- メール等は使わず、Web アプリ内で完結する仕組みにします。

### 完成イメージ

招待する方
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/dc9387ec-50e8-fb61-b5bf-767368a748ed.png" width=400 />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/a5f50d8e-fbd5-f0e1-c41c-30291b631d1e.png" width=400 />

招待された方
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/6b1889ba-c66b-d0ab-335f-61e1299e03fb.png" width=400 />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/e5d8b7bb-ca54-b82a-f3b8-af27ac8c10ce.png" width=400 />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/8d622cb0-0f03-ba19-2301-609ba75820fd.png" width=400 />

# やりたい事の整理

この Web アプリでは ["公演ユーザ"](https://qiita.com/satamame/items/959e21ade18c48e1b4d6#%E5%85%AC%E6%BC%94%E3%83%A6%E3%83%BC%E3%82%B6%E3%81%AE%E3%83%A2%E3%83%87%E3%83%AB) というモデルを使って、Django が管理しているアカウントと各「公演」への参加状態を関連付けています。
したがって、招待された人が「参加」を選択した時の処理は、"公演ユーザ" を新規で作成する処理になります。

### 「招待状」のモデルを作る

以下のフィールドを持たせます。

|フィールド|内容|
|---|---|
|production|招待先の「公演」|
|inviter|招待する人 (Django が管理しているアカウント)|
|invitee|招待される人 (Django が管理しているアカウント)|
|exp_dt|招待状の有効期限|

### 「招待状」を作成する仕組みを作る

- 「メンバ一覧」という画面があるので、そこに「招待する」というボタンを作ります。
- このボタンを押すと「招待状」を作る画面を表示するようにします。
- 「招待状」を作る画面では、「招待される人」を指定できるようにする必要があります (それ以外のフィールドの内容はアプリ側で決められます)。

### 「招待状」を表示する仕組みを作る

- 「公演」の所有者が「メンバ一覧」画面を表示した時に、自分が作った「招待状」が表示されるようにします。
    - ここで削除もできるようにします。
- 招待されている人が「参加している公演」画面 (ログイン直後に表示される画面) を表示した時に、自分宛ての「招待状」が表示されるようにします。
    - ただし、有効期限を過ぎているものは表示されないようにします。
    - ここで「公演」への参加と、「招待状」の削除ができるようにします。

### 「招待状」から「公演」に参加する仕組みを作る

- 招待された人が「参加」を選択した時の処理は、"公演ユーザ" を新規で作成する処理になります。

# 招待状のモデル

※コードでは「招待状」ではなく「招待」と呼んでいます。
※「公演」に参加しているメンバをまとめて「座組」と呼んでいます。

```python
from datetime import datetime, timezone
from django.conf import settings
from django.db import models


class Invitation(models.Model):
    '''座組への招待
    '''
    production = models.ForeignKey(Production, verbose_name='公演',
        on_delete=models.CASCADE)
    inviter = models.ForeignKey(settings.AUTH_USER_MODEL,
        verbose_name='招待する人',
        related_name='inviter', on_delete=models.CASCADE)
    invitee = models.ForeignKey(settings.AUTH_USER_MODEL,
        verbose_name='招待される人',
        related_name='invitee', on_delete=models.CASCADE)
    exp_dt = models.DateTimeField(verbose_name='期限')
    
    class Meta:
        verbose_name = verbose_name_plural = '座組への招待'
        ordering = ['exp_dt']
    
    def __str__(self):
        return f'{self.invitee} さんへの {self.production} への招待'
    
    def expired(self):
        '''この招待が期限切れかどうかを返す
        '''
        now = datetime.now(timezone.utc)
        return now > self.exp_dt
```

- `Production` は、この Web アプリで[定義](https://qiita.com/satamame/items/959e21ade18c48e1b4d6#%E5%85%AC%E6%BC%94-%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E7%9A%84%E5%8D%98%E4%BD%8D-%E3%81%AE%E3%83%A2%E3%83%87%E3%83%AB)している「公演」のモデルです。
- `inviter`, `invitee` は、どちらも Django が管理しているアカウントなので、`settings.AUTH_USER_MODEL` のオブジェクトへの参照となります。
- `AUTH_USER_MODEL` については[こちら](https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#substituting-a-custom-user-model)を御覧ください。

# 招待状を作成する仕組み

以下、View や Template の説明になります。
`production` というアプリケーションの中で作っているので、URL の指定が 'production:usr_list' のようになっています。
呼び出しのための `urls.py` の書き方については、[`production/urls.py` のソース](https://github.com/satamame/pscweb2/blob/master/production/urls.py) を見てください。

### View を作る

`CreateView` を継承して 招待状を作成するための View を作ります。

```python:views.py
from django.views.generic.edit import CreateView
from django.contrib import messages
from django.contrib.auth import get_user_model
from .view_func import *
from .models import Production, ProdUser, Invitation


class InvtCreate(LoginRequiredMixin, CreateView):
    '''Invitation の追加ビュー
    '''
    model = Invitation
    fields = ()
```

- `fields` を空にしているのは、ちょっとトリッキーかも知れませんが、フィールド (`id` 以外) の値をすべて `Form` ではなくコードでセットするためです。

```python:views.py
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # 所有権を検査してアクセス中の公演ユーザを取得する
        prod_user = test_owner_permission(self)
        
        # production を view の属性として持っておく
        # テンプレートで固定要素として表示するため
        self.production = prod_user.production
        
        return super().get(request, *args, **kwargs)
```

- 公演の所有権を持つユーザだけにアクセスを許可するため、`test_owner_permission()` というメソッドを呼んでいます。
    - このメソッドが何をしているかについては、[`production/view_func.py` のソース](https://github.com/satamame/pscweb2/blob/master/production/view_func.py) を見てください。

```python:views.py
    def post(self, request, *args, **kwargs):
        '''保存時のリクエストを受けるハンドラ
        '''
        # 所有権を検査してアクセス中の公演ユーザを取得する
        prod_user = test_owner_permission(self)
        
        # フォームで入力された「招待する人の ID」
        invitee_value = request.POST['invitee_id']
        
        # それに一致するユーザを view の属性として持っておく
        user_model = get_user_model()
        invitees = user_model.objects.filter(username=invitee_value)
        if len(invitees) > 0:
            self.invitee = invitees[0]
        
        # prod_user, production を view の属性として持っておく
        # バリデーションと保存時に使うため
        self.prod_user = prod_user
        self.production = prod_user.production
        
        return super().post(request, *args, **kwargs)
```

- 招待状を作成する画面では UI の文脈上、「招待される人」の事を「招待する人」と書いています。
- 「招待される人」は `ForeignKey` 型なので、通常は `Form` で選択式の UI を表示するのですが、すべてのユーザを選択肢に出すのもどうかと思うので、テキストでアカウント ID (実際のフィールド名は `username`) を入力する形にしました。
- 入力された ID を元にユーザのオブジェクトを見つける処理は `post()` メソッドでやっています。
    - `User` モデルを参照するには `get_user_model()` を使います ([> ドキュメント](https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#referencing-the-user-model))。  
    `settings.AUTH_USER_MODEL` は文字列だからです。
- 見つかった時だけ View の `invitee` 属性をセットするようにして、バリデーションの時にこれの存在チェックをするようにしています。

```python:views.py
    def form_valid(self, form):
        '''バリデーションを通った時
        '''
        # POST ハンドラで招待するユーザを取得できていなかったら、追加失敗
        if not hasattr(self, 'invitee'):
            return self.form_invalid(form)
        
        # 公演ユーザのユーザ ID リスト
        prod_users = ProdUser.objects.filter(production=self.production)
        prod_user_user_ids = [prod_user.user.id for prod_user in prod_users]
        
        # 招待中のユーザの ID リスト
        invitations = Invitation.objects.filter(production=self.production)
        current_invitee_ids = [invt.invitee.id for invt in invitations]
        
        # 公演ユーザや招待中のユーザを招待することは出来ない。
        if self.invitee.id in prod_user_user_ids\
            or self.invitee.id in current_invitee_ids:
            return self.form_invalid(form)
        
        # 追加しようとするレコードの各フィールドをセット
        instance = form.save(commit=False)
        instance.production = self.production
        instance.inviter = self.prod_user.user
        instance.invitee = self.invitee
        # 期限は7日
        # デフォルトで UTC で保存されるが念の為 UTC を指定
        instance.exp_dt = datetime.now(timezone.utc) + timedelta(days=7)
        
        messages.success(self.request, str(instance.invitee) + " さんを招待しました。")
        return super().form_valid(form)
```

- `fields` が空なので (`Form` がバリデートするものが無いので)、ユーザが招待状を作ろうとすると必ずこのメソッドを通ります。
- 指定した ID のユーザの存在、そのユーザがすでに参加していないか、招待されていないかをチェックします。
- すべてのフィールドをセットしてスーパーメソッドを呼べば、作成した招待状が保存されます。

```python:views.py
    def get_success_url(self):
        '''追加に成功した時の遷移先を動的に与える
        '''
        prod_id = self.prod_user.production.id
        url = reverse_lazy('production:usr_list', kwargs={'prod_id': prod_id})
        return url
    
    def form_invalid(self, form):
        '''追加に失敗した時
        '''
        messages.warning(self.request, "招待できませんでした。")
        return super().form_invalid(form)
```

- `get_success_url()` と、失敗した時の `form_invalid()` です。
- 戻り先 (`production:user_list`) は、「招待する」ボタンを設置しようとしている View (「メンバ一覧」画面) です。

### Template を作る

```html:invitation_form.html
{% extends 'base.html' %}

{% block content %}
<h1 style="margin: 0px;">
<a href="{% url 'production:usr_list' prod_id=view.production.id %}">◀</a>
座組への招待
</h1>

<div>&nbsp;</div>

<form method="post">
    {% csrf_token %}
    <table>
    <tr><th>公演</th><td>{{ view.production }}</td></tr>
    <tr><th>招待する人の ID</th><td><input type="text" name="invitee_id" /></td></tr>
    </table>
    <input type="submit" value="招待">
</form>
{% endblock %}
```

- `invitee_id` を入力させて「招待」ボタンで submit しているだけです。

# 作った招待状を表示する仕組み

自分が作った招待状を表示する画面は、「メンバ一覧」画面です。ここに「招待する」ボタンも設置します。
ユーザシナリオは以下のようになります。

1. 公演ごとの「メンバ一覧」画面で「招待する」を押す。
1. 招待状を作成するための (上記で作った) View が表示される。
1. 招待する (される) ユーザの ID を入力してフォームを submit する。
1. 「メンバ一覧」画面に戻って、自分が作った招待状を確認できる。

### View を作る

```python:views.py
from django.views.generic import ListView
from django.core.exceptions import PermissionDenied


class UsrList(LoginRequiredMixin, ListView):
    '''ProdUser のリストビュー
    '''
    model = ProdUser
    
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # アクセス情報から公演ユーザを取得しアクセス権を検査する
        prod_user = accessing_prod_user(self, kwargs['prod_id'])
        if not prod_user:
            raise PermissionDenied
        
        # テンプレートから参照できるよう、ビューの属性にしておく
        self.prod_user = prod_user
        
        # 招待中のユーザを表示するため、ビューの属性にする
        self.invitations = Invitation.objects.filter(production=prod_user.production)
        
        return super().get(request, *args, **kwargs)
    
    # 以下略
```

- 本来は「公演ユーザ」の一覧を表示する View ですが、同じ画面に招待状の一覧も表示できるよう、招待状を View の属性 (`invitations`) にして Template から参照できるようにしています。
- 前の節で「自分が作った招待状」と書きましたが、厳密には「この公演に招待している招待状」を取得しています。理由は、「公演」の所有者が変わっても表示されるようにするためです。

### Template を作る

招待状を表示している部分だけ抜粋します。
全体を見るには [GitHub 上のコード](https://github.com/satamame/pscweb2/blob/master/production/templates/production/produser_list.html)にアクセスして下さい。

```html:produser_list.html
{% if view.prod_user.is_owner and view.invitations %}
<div style="border:solid thin lightgray; border-radius:10px; padding: 10px; margin:5px 0 20px;">
<p><strong>招待中のユーザ</strong></p>
<table>
    <tr>
        <th>ユーザ ID</th>
        <th>姓</th>
        <th>名</th>
        <th>招待の期限</th>
    </tr>
    {% for item in view.invitations %}
    <tr>
        <td>{{ item.invitee.username }}</td>
        <td>{{ item.invitee.last_name }}</td>
        <td>{{ item.invitee.first_name }}</td>
        {% if item.expired %}
            <td style="color:red;">{{ item.exp_dt }}</td>
        {% else %}
            <td>{{ item.exp_dt }}</td>
        {% endif %}
        <td><a href="{% url 'production:invt_delete' pk=item.id from='usr_list' %}" class="deletelink">削除</a></td>
    </tr>
{% endfor %}
</table>
</div>
{% endif %}
```

- 最初の `if` 文で、アクセス中のユーザが公演の所有者である場合、かつ View に `invitations` 属性が存在する (そして空でない) 場合という条件で、招待状の一覧を表示するようにしています。
- 招待の期限が切れている場合は赤く表示したいので、上で作った `Invitation` クラスの `expired` メソッドを使っています。
    - Template からメソッドを呼べることがポイントです。
- 各招待状には「削除」ボタンをつけて、これから作る削除用の View にジャンプするようにします。
    - 招待された人も同じ View を使って削除できるようにするので、URL に `from` というパラメタをつけて戻り先を憶えるようにしています。

# 受け取った招待状を表示する仕組み

受け取った招待状を表示する画面は、「参加している公演」画面です。
本来は参加している公演の一覧を表示する画面ですが、作った招待状を表示する時と同じように、条件を満たした時だけ招待状のためのエリアを表示します。

### View を作る

```python:views.py
class ProdList(LoginRequiredMixin, ListView):
    '''Production のリストビュー
    
    ログインユーザの公演のみ表示するため、モデルは ProdUser
    '''
    model = ProdUser
    template_name = 'production/production_list.html'
    
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # 座組への招待を表示するため、ビューの属性にする
        now = datetime.now(timezone.utc)
        self.invitations = Invitation.objects.filter(invitee=self.request.user,
            exp_dt__gt=now)
        
        return super().get(request, *args, **kwargs)
    
    # 以下略
```

- 作った招待状を表示する時と同じように、招待状を View の属性にしています。
- 作った招待状を表示する時との違いは、最初から期限が切れていない招待状だけを取り出している点です。
    - `expired()` メソッドは使わず、QuerySet の `filter()` で抽出しています。
    - ここでやっているような `xx__gt=xx` といった条件の書き方については[こちら](https://docs.djangoproject.com/en/3.0/ref/models/querysets/#field-lookups)をご覧ください。

### Template を作る

招待状を表示している部分だけ抜粋します。
全体を見るには [GitHub 上のコード](https://github.com/satamame/pscweb2/blob/master/production/templates/production/production_list.html)にアクセスして下さい。

```html:production_list.html
{% if view.invitations %}
<div style="border:solid thin lightgray; border-radius:10px; padding: 10px; margin:5px 0 20px;">
<p><strong>招待されている座組</strong></p>
<table>
    <tr>
        <th>公演名</th>
        <th>招待主の ID</th>
        <th>招待の期限</th>
    </tr>
    {% for item in view.invitations %}
    <tr>
        <td>{{ item.production }}</td>
        <td>{{ item.inviter.username }}</td>
        {% if item.expired %}
            <td style="color:red;">{{ item.exp_dt }}</td>
        {% else %}
            <td>{{ item.exp_dt }}</td>
        {% endif %}
        <td>
            <a href="{% url 'production:prod_join' invt_id=item.id %}" class="addlink">参加</a>
            <a href="{% url 'production:invt_delete' pk=item.id from='prod_list' %}" class="deletelink">削除</a>
        </td>
    </tr>
{% endfor %}
</table>
</div>
{% endif %}
```

- 受け取った招待状には「削除」ボタンに加えて「参加」ボタンも表示します。
- 「参加」ボタンを押すと、参加の確認と処理をする View にジャンプするようにします。

# 招待状を削除する仕組み

作った招待状や受け取った招待状を表示する時、Template に「削除」ボタンを配置しました。
そのリンク (`production:invt_delete`) からアクセスする View を作ります。

### View を作る

```python:views.py
from django.views.generic.edit import DeleteView


class InvtDelete(LoginRequiredMixin, DeleteView):
    '''Invitation の削除ビュー
    '''
    model = Invitation
    template_name_suffix = '_delete'
```

- `template_name_suffix` はデフォルトだと '_confirm_delete' となるようです。  
このあたりはお好みで。

```python:views.py
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # 公演の所有者または招待の invitee であることを検査する
        invt = self.get_object()
        prod_id = invt.production.id
        prod_user = accessing_prod_user(self, prod_id)
        
        is_owner = prod_user and prod_user.is_owner
        is_invitee = self.request.user == invt.invitee
        
        if not (is_owner or is_invitee):
            raise PermissionDenied
        
        return super().get(request, *args, **kwargs)
```

- 削除しようとするオブジェクト (招待状) から「公演」の ID を取って、アクセス中のユーザがその公演の所有者か、または招待された人であることをチェックしています。
- `accessing_prod_user()` メソッドが何をしているかについては、[`production/view_func.py` のソース](https://github.com/satamame/pscweb2/blob/master/production/view_func.py) を見てください。

```python:views.py
    def post(self, request, *args, **kwargs):
        '''保存時のリクエストを受けるハンドラ
        '''
        # 公演の所有者または招待の invitee であることを検査する
        invt = self.get_object()
        prod_id = invt.production.id
        prod_user = accessing_prod_user(self, prod_id)
        
        is_owner = prod_user and prod_user.is_owner
        is_invitee = self.request.user == invt.invitee
        
        if not (is_owner or is_invitee):
            raise PermissionDenied
        
        return super().post(request, *args, **kwargs)
```

- `POST` ハンドラもやっていることは `GET` と同じです。  
重複が気持ち悪ければメソッド化しても良いと思います。

```python:views.py
    def get_success_url(self):
        '''削除に成功した時の遷移先を動的に与える
        '''
        if self.kwargs['from'] == 'usr_list':
            prod_id = self.object.production.id
            url = reverse_lazy('production:usr_list', kwargs={'prod_id': prod_id})
        else:
            url = reverse_lazy('production:prod_list')
        return url
    
    def delete(self, request, *args, **kwargs):
        '''削除した時のメッセージ
        '''
        result = super().delete(request, *args, **kwargs)
        messages.success(
            self.request, str(self.object) + " を削除しました。")
        return result
```

- 「削除」ボタンのリンク URL に `from` パラメタを付けたので、それを使って戻り先の振り分けをしています。
- `get()` や `post()` メソッド内でなくても、`self.kwargs` で `URLConf` にアクセスできます。
- `URLConf` の指定は、`production/urls.py` で以下のようにしています ([> ソース](https://github.com/satamame/pscweb2/blob/master/production/urls.py))。  

    ```path('invt_delete/<int:pk>/<str:from>/', views.InvtDelete.as_view(), name='invt_delete'),```

### Template を作る

「削除する」ボタンと「キャンセル」ボタンだけのシンプルなページです。

```html:invitation_delete.html
{% extends 'base.html' %}

{% block content %}
<h1 style="margin: 0px;">
{% if view.kwargs.from == 'usr_list' %}
<a href="{% url 'production:usr_list' prod_id=object.production.id %}">◀</a>
{% else %}
<a href="{% url 'production:prod_list' %}">◀</a>
{% endif %}
座組への招待の削除
</h1>

<form method="post">{% csrf_token %}
    <p>この招待を削除しますか？</p>
    <input type="submit" value="削除する">
    <input type="button"
        {% if view.kwargs.from == 'usr_list' %}
        onclick="location.href='{% url 'production:usr_list' prod_id=object.production.id %}'"
        {% else %}
        onclick="location.href='{% url 'production:prod_list' %}'"
        {% endif %}
        value="キャンセル">
</form>

<table>
    <tr><th>公演</th><td>{{ object.production }}</td></tr>
    <tr><th>招待される人の ID</th><td>{{ object.invitee.username }}</td></tr>
    <tr><th>姓</th><td>{{ object.invitee.last_name }}</td></tr>
    <tr><th>名</th><td>{{ object.invitee.first_name }}</td></tr>
    <tr><th>期限</th>
    {% if object.expired %}
        <td style="color:red;">{{ object.exp_dt }}</td>
    {% else %}
        <td>{{ object.exp_dt }}</td>
    {% endif %}
    </tr>
</table>
{% endblock %}
```

- 「招待される人の ID」などは、`from` が 'prod_list' の場合 (招待されている人が開いた場合) は隠すなどしても良いかも知れません。
- Template 内で `URLConf` にアクセスするには `view.kwargs.from` などとします ('from' がパラメタ名の場合)。

# 招待状から公演に参加する仕組み

### View を作る

「参加」を選択した時の処理は、"公演ユーザ" を新規で作成する処理なので、以下のような `CreateView` を作ります。
ただし Template の名前は分かりやすく 'production_join.html' としました。

```python:views.py
from django.http import Http404


class ProdJoin(LoginRequiredMixin, CreateView):
    '''公演に参加するビュー
    '''
    model = ProdUser
    fields = ('production', 'user')
    template_name = 'production/production_join.html'
    success_url = reverse_lazy('production:prod_list')
```

- `fields` に指定した `production` と `user` は、ユーザに入力してもらう訳ではないのですが、hidden にして Template に埋め込むことで `Form` から受け取れるようにしています。
- もちろん[「招待状を作成する仕組み」](#招待状を作成する仕組み)でやったように、コードでセットすることも出来ます。

```python:views.py
    def get(self, request, *args, **kwargs):
        '''表示時のリクエストを受けるハンドラ
        '''
        # 招待されているか検査し、参加できる公演を取得
        self.production = self.production_to_join()
        
        return super().get(request, *args, **kwargs)
    
    def post(self, request, *args, **kwargs):
        '''保存時のリクエストを受けるハンドラ
        '''
        # 招待されているか検査し、参加できる公演を取得
        self.production = self.production_to_join()
        
        return super().post(request, *args, **kwargs)
```

- `GET` および `POST` ハンドラでは、`URLConf` の情報を元に、どの公演に参加するのかを属性として取っておきます。
- 参加先の公演 (`Production`) のオブジェクトを取得する `production_to_join()` メソッドは、以下のようにしてあります。

```python:views.py
    def production_to_join(self):
        '''招待されているか検査し、参加できる公演を返す
        '''
        # 招待がなければ 404 エラーを投げる
        invt_id = self.kwargs['invt_id'];
        invts = Invitation.objects.filter(id=invt_id)
        if len(invts) < 1:
            raise Http404
        invt = invts[0]
        
        # 招待が期限切れなら 404 エラーを投げる
        now = datetime.now(timezone.utc)
        if now > invt.exp_dt:
            raise Http404
        
        # アクセス中のユーザが invitee でなければ PermissionDenied
        user = self.request.user
        if user != invt.invitee:
            raise PermissionDenied
        
        return invt.production
```

- [「受け取った招待状を表示する仕組み」](#受け取った招待状を表示する仕組み)のところで、「参加」ボタンのリンクに `invt_id` というパラメタを埋め込んであるので、これを使って招待状 (`Invitation`) のオブジェクトを取得しています。
- ここ (公演に参加するビュー) へ来るためのパスは、[`urls.py`](https://github.com/satamame/pscweb2/blob/master/production/urls.py) で以下のようにしています ([> ソース](https://github.com/satamame/pscweb2/blob/master/production/urls.py))。

    ```path('prod_join/<int:invt_id>/', views.ProdJoin.as_view(), name='prod_join'),```

- 期限切れについてはちゃんとメッセージを表示した方が良いですが、ここでは単に 404 を返しています。

```python:views.py
    def form_valid(self, form):
        '''バリデーションを通った時
        '''
        # 招待を検査
        invt_id = self.kwargs['invt_id'];
        invts = Invitation.objects.filter(id=invt_id)
        if len(invts) < 1:
            return self.form_invalid(form)
        invt = invts[0]
        
        # 保存するレコードを取得する
        new_prod_user = form.save(commit=False)
        
        # 正しい公演がセットされているか
        if new_prod_user.production != invt.production:
            return self.form_invalid(form)
        
        # 正しいユーザがセットされているか
        if new_prod_user.user != invt.invitee:
            return self.form_invalid(form)
        
        # 招待を削除
        invt.delete()
        
        messages.success(self.request, str(invt.production) + " に参加しました。")
        return super().form_valid(form)
    
    def form_invalid(self, form):
        '''参加に失敗した時
        '''
        messages.warning(self.request, "参加できませんでした。")
        return super().form_invalid(form)
```

- `Form` から正しいフィールド値 (この場合は `production` と `user`) を受け取った後に View の `form_valid()` メソッドが呼ばれるので、改めて「招待状」の内容と突き合わせます。
- このようなチェックが必要な理由は、フィールド値が一旦 `Invitation` オブジェクトを離れて HTML に晒されているからです。
- チェックを通過したらスーパーメソッドを呼べば "公演ユーザ" が作成されますが、その前に「招待状」を削除しておきます。

### Template を作る

「参加」ボタンと「あとで」ボタンだけのシンプルなページです。

```html:production_join.html
{% extends 'base.html' %}

{% block content %}
<h1 style="margin: 0px;">
<a href="{% url 'production:prod_list' %}">◀</a>
公演に参加
</h1>

<div>&nbsp;</div>

<p>
{{ view.production }} に招待されています。参加しますか？
</p>

<form method="post">
    {% csrf_token %}
    <input type="hidden" name="production" value="{{ view.production.id }}">
    <input type="hidden" name="user" value="{{ view.request.user.id }}">
    <input type="submit" value="参加">
    <input type="button"
        onclick="location.href='{% url 'production:prod_list' %}'"
        value="あとで">
</form>
{% endblock %}
```

- `POST` ハンドラ以降の処理に渡したいフィールド値を、hidden にして直に書き込んでいます。
- `ForeignKey` 型のフィールド値を直に書く場合は、オブジェクトの `id` を使います。

# まとめ

「公演」という "プロジェクト的単位" (グループ) にユーザを招待したり、招待を受けたユーザがそこに参加したりする機能の実装を紹介しました。
使ったテクニックについて以下にまとめます。

- モデルのフィールドとして `User` 型を指定する時は `settings.AUTH_USER_MODEL` を使い、View のメソッドで `User` モデルを参照するには `get_user_model()` を使いました。
    - 実はモデルのフィールドとして指定する時も `get_user_model()` は使えるそうです。
- オブジェクトを作成する View でフィールド値をすべて (`id` 以外) コードでセットする場合でも、`fields` を空にすれば `CreateView` が使えました。
- Template で選択式の UI を使いたくない場合は、オブジェクトを見つけるためのフィールド (`username` など) の値を入力するようにして、コード (`post()` メソッド) で見つけるようにしました。
- Python ではオブジェクトの属性を好きな時に追加できるので、有効な値が見つかった時だけ View の属性として持っておいて、あとで属性の有無を調べて (`hasattr()` メソッドを使って) バリデートする事ができました。
- 表示したいデータを View の属性にしておけば、Tamplate 側で属性の有無 (あっても空かどうか) を調べて、必要に応じて表示する事ができました。
- Template からは、View のメソッドを呼んで返り値を使う事もできました。
- 同じ View に複数のページからジャンプして戻り先を動的に決めたい場合、その View への URL に `from` 等といったパラメタを持たせれば可能でした。
- `get()` や `post()` メソッドで受け取った `kwargs` は、あとからでも `self.kwargs` として参照可能でした。
    - View の属性なので Template からも `view.kwargs.パラメタ名` として参照可能でした。
- `GET` の時点で作ろうとしているオブジェクトのフィールド値が分かっている場合は、Template に hidden で書いてしまうという方法もありました。
- `ForeignKey` 型のフィールド値を (hidden 等として) Template に直に書く場合は、そのオブジェクトの `id` を使えば、`Form` で正しく処理されました。