Djangoで更新可能な一覧画面を作成する

自治会の運営を楽にしたいと思って[自治会ネット](https://jitikai.net)というWEBサービスを立ち上げました。
環境はAWS + Python(Django)です。
構築にあたって、偉大なる先人方の記事に助けられたので微力ながら自分も誰かの助けになれたらと思い、記事を書いてみることにしました。

## やりたいこと
ユーザーの一覧を表示しつつ一部の情報だけを変更できるような画面を作りたい。
![スクリーンショット 2021-01-29 9.36.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1064244/cc4e7207-5558-87d2-5761-d2209fc46977.png)

djangoはデフォルトで用意されている管理画面でもそうですが、一覧画面→詳細画面に遷移して更新するのが基本です。
でもたくさんのデータを１つ１つ開いて更新していくのは面倒なので、権限とかの更新は一覧画面で一度に行えるようにしたい。
ニーズはありそうなのですが、意外と情報が少なかったので書いてみました。

## 実現方法
[django-extra-views](https://django-extra-views.readthedocs.io/en/latest/index.html)というライブラリを使います。
今回使ったのはこの中の[ModelFormSetView](https://django-extra-views.readthedocs.io/en/latest/pages/formset-views.html#modelformsetview)

## 手順
djangoの基本的な使い方については他の方が記事を書いてくださってますので省略します。

#### ライブラリのインストール
```
pip install django-extra-views
```

#### APPの追加
```
INSTALLED_APPS = [
    ...
    'extra_views',
    ...
]
```

#### models.py
```
from django.db import models

class Group(models.Model):
    code = models.CharField(max_length=15, primary_key=True)
    name = models.CharField('グループ名', max_length=30)
    
    def __str__(self):
        return self.code

class Member(models.Model):

    full_name = models.CharField('名前', max_length=150)
    group = models.ForeignKey(to=Group, on_delete=models.CASCADE)
    auth = models.BooleanField(
        '権限A',
        default=False,
    )
```

#### forms.py
```
from django import forms
from django.db import models
from django.forms import ModelForm

from .models import Member, Group

class MemberUpdateForm(ModelForm):
    class Meta:
        model = Member
        fields = ( 'full_name', 'group', 'auth')   
```

#### urls.py
```
from sample import views as sample
urlpatterns = [
    path('update_sample', sample.ListUpdateView.as_view(), name='update_sample'),
]
```
#### view.py
```
from extra_views import ModelFormSetView
from .models import Member
from .forms import MemberUpdateForm

class ListUpdateView(ModelFormSetView):   
    model = Member
    template_name = 'update_sample.html'
    form_class = MemberUpdateForm

```

#### update_sample.html
```
<form method="post">
  {{ formset }}
  <div>
    <button class="btn btn-warning" type="submit">変更</button>
  </div>
</form>
```

## ここまでの結果
![スクリーンショット 2021-01-29 10.40.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1064244/a4217616-5f7c-c551-aacd-ab9a7fa08d2b.png)

とりあえず更新可能な一覧が表示されました。
最後の一行は新規行を追加するためのものです。
しかし、このままでは見た目も悪いし名前も編集できてしまうので修正していきます。
まずは{{ formset }}を展開します。

#### update_sample.html
```
<form id="members_form" method="post">
  {% csrf_token %}
  {{ formset.management_form }}
  <table class="table">
    <thead>
      <tr>
        <th scope="col">ID</th>
        <th scope="col">名前</th>
        <th scope="col">グループ</th>
        <th scope="col">権限A</th>
      </tr>
    </thead>
    <tbody>
      {% for form in formset %}
      <tr>
        <td>{{ form.id }}</td>
        <td>{{ form.full_name }}</td>
        <td>{{ form.group }}</td>            
        <td>{{ form.auth }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
  <div>
    <button class="btn btn-warning" type="submit">変更</button>
  </div>
</form>

```

formsetには「management_form」という、formのトータル行数や元の行数を持ってる要素が含まれているので、formsetを展開する場合には{{ formset.management_form }}を記述する必要があります。
#### management_form の中身
```
<input type="hidden" name="form-TOTAL_FORMS" value="3" id="id_form-TOTAL_FORMS">
<input type="hidden" name="form-INITIAL_FORMS" value="2" id="id_form-INITIAL_FORMS"><input type="hidden" name="form-MIN_NUM_FORMS" value="0" id="id_form-MIN_NUM_FORMS">
<input type="hidden" name="form-MAX_NUM_FORMS" value="1000" id="id_form-MAX_NUM_FORMS">
```

また、formset = 複数formをまとめたものなので{% for form in formset %}で
formを一行ずつ取り出して表示してあげます。
bootstrapのtableにセットしてあげると簡単に見やすくできます。

ID（PK)はhidden項目ですが、書かないとformのvalidationでエラーになるので更新できません。
該当の列をdisplay: noneにするなどして見えなくしておきましょう。

## ここまでの結果
![スクリーンショット 2021-01-29 11.01.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1064244/48731d1d-97e8-0fdd-f84c-d87042abbfa1.png)
だいぶ見た目がマシになりました。
次に名前を編集不可にするため、readonly ~~disabled~~(disabledだと更新できないです)属性をつけてcssでテキストボックスの見た目を消します。
（注）disableにしても開発者ツールとか使えば編集できてしまうので完全に禁止できるわけではないです。
何か良い方法ご存知の方がいれば教えてください。

属性の追加は[こちらの記事](https://wonderwall.hatenablog.com/entry/2018/03/18/210000)参照
#### update_sample.html
```
{% load widget_tweaks %}                      #追加
<form id="members_form" method="post">
  {% csrf_token %}
  {{ formset.management_form }}
  <table class="table">
    <thead>
      <tr>
        <th class="hidden" scope="col">ID</th> #変更
        <th scope="col">名前</th>
        <th scope="col">グループ</th>
        <th scope="col">権限A</th>
      </tr>
    </thead>
    <tbody>
      {% for form in formset %}
      {% if not forloop.last %} #追加
      <tr>
        <td class="hidden">{{ form.id }}</td> #変更
        <td>{{ form.full_name }}</td>
        <td>{{ form.group }}</td>            
        <td>{{ form.auth }}</td>
      </tr>
      {% endif %} #追加
      {% endfor %}
    </tbody>
  </table>
  <div>
    <button class="btn btn-warning" type="submit">変更</button>
  </div>
</form>
```
#### css
```
input {
  border: 0;
  color: #000000;
  background-color: rgba(255,255,255,255);
}
.hidden {
  display: none;
}
```

最後に、グループの選択肢をコードではなく名前にします。
#### forms.py
```
from django import forms
from django.db import models
from django.forms import ModelForm
from .models import Member, Group

# nameをlabelにするModelChoiceField
class ModelNameChoiceField(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        return "%s" % obj.name

class MemberUpdateForm(ModelForm):
    class Meta:
        model = Member
        fields = ( 'full_name', 'group', 'auth')   

    group = ModelNameChoiceField(Group.objects)
```

## 完成！
![スクリーンショット 2021-01-29 11.48.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1064244/8c15b549-5c7c-cae1-07b5-4194ed1992d6.png)

## 所感
初めて記事を書きましたが、間違ってないか実際に動かしながら確認するので時間もかかるし大変ですね。
先人の方々に改めて感謝です。
今後はAWSでの環境構築などの記事を書いていけたらと思います。


## 参考　
[Python + Django で、１対多モデルの登録画面を作成する](https://qiita.com/t-iguchi/items/e7170d97ecb5b1cff1a2)