iT邦幫忙

2023 iThome 鐵人賽

DAY 28
0

前言

目前我們的任務列表沒有紀錄建立人,所以雖然要登入才能建立任務,但是無法區分是誰建立的,也無法限制大家只能調整自己的任務。今天我們要來做的事情就是要讓大家只能看到編輯刪除自己建立的任務,但是管理者除外,他們可以操作所有的任務。

題外話

再開始今天的任務之前我們先來調整一個東西,也就是之前安裝的套件 djangorestframework-types 版本。

大家可以發現我們目前安裝的是 0.8.0 但是套件本身的套件管理上有些問題,他的 0.7.1 版反而才是最新的版本,詳細的討論可以參考這個 PR,總之我們先把版本調整成 0.7.1 吧~

poetry add --group lint djangorestframework-types@^0.7.1

這樣我們就會將套件版本調整成 ^0.7.1 其中的 ^ 代表的是我們接受的套件為 >=0.7.1 但是 <0.8.0 的,詳細的規則可以參考這個文件

P.S. 今天會需要降版是因為 0.8.0 版本未將這個 Bug 修復合併,但 0.7.1 版本是有的,且根據 PR 的討論 0.8.0 版看起來應該是作者不知道什麼原因發佈的(GitHub 上也只有到 0.7.1 版)

修改 Model

首先我們要先在任務 Model 中添加一個建立者欄位,讓我們編輯 server/app/todo/models.py 檔案

+from django.contrib import auth
from django.db import models

from server.utils import models as model_utils

+User = auth.get_user_model()
+

class Tag(models.Model):

# ...... 中間省略 ......

class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = model_utils.CreatedAtField()
    updated_at = model_utils.UpdatedAtField()
    category = models.ForeignKey(Category, on_delete=models.PROTECT)
    attachment = models.FileField(blank=True, upload_to="task/attachments/")
+   creator = models.ForeignKey(User, on_delete=models.PROTECT, null=True)

    def __str__(self):
        return self.title

這邊我們先拿到 User 的 Model,因為 Django 支援 User Model 的替換所以我們不能直接 import Model 來使用,需要透過 auth.get_user_model() 獲得目前的 User Model 是什麼,接著我們在任務中建立一個 creator 欄位。

Creator 欄位是必填的,但因為我們一開始沒有建立這個欄位,所以我們需要先設定為 null=True 等等我們會將舊的任務指向「未知的」使用者。

接著讓我們產生遷移檔(記得啟動虛擬環境)

python manage.py makemigrations

接著我們建立一個空的遷移檔

python manage.py makemigrations --empty --name set_creator_to_tasks todo

並修改 server/app/todo/migrations/0017_set_creator_to_tasks.py 檔案(可以直接將下方內容貼入檔案中)

# Generated by Django 4.2.5 on 2023-10-13 13:42

from django.conf import settings
from django.contrib.auth import hashers
from django.db import migrations


def set_default_category_to_task(apps, schema_editor):
    task_model = apps.get_model("todo", "Task")
    if task_model.objects.count() == 0:
        return

    user_model = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
    unknown_user, _ = user_model.objects.update_or_create(
        username="unknown",
        defaults={"is_active": False, "password": hashers.make_password(None)},
    )

    task_model.objects.update(creator=unknown_user)


class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ("todo", "0016_task_creator"),
    ]

    operations = [
        migrations.RunPython(
            set_default_category_to_task,
            reverse_code=migrations.RunPython.noop,
            atomic=True,
        ),
    ]

這邊我們做的事情跟之前處理分類時的差不多,這邊我們會先產生一個 username 為 unknown 的使用者,並確保他不能被登入(is_active 是 False 與 password 是 None 的 hash)再將所有舊有任務關聯到這個「未知」使用者。

現在我們已經將舊的任務的使用者都設定好了,現在將 creator 變為不可 null 的欄位,讓我們編輯 server/app/todo/models.py 檔案

# ...... 以上省略 ......

class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = model_utils.CreatedAtField()
    updated_at = model_utils.UpdatedAtField()
    category = models.ForeignKey(Category, on_delete=models.PROTECT)
    attachment = models.FileField(blank=True, upload_to="task/attachments/")
-   creator = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
+   creator = models.ForeignKey(User, on_delete=models.PROTECT)

    def __str__(self):
        return self.title

接著讓我們產生遷移檔

python manage.py makemigrations

跳出選項後我們選 2 因為我們已經透過 RunPython 處理過資料了。

接著讓我們把遷移檔套用到資料庫

python manage.py migrate

P.S. 這邊的操作都與 Day22 的概念相同,如果忘記了可以回頭複習一下。

修改序列化

現在我們已經建立好資料庫欄位了,接著讓我們來修改一下序列化。使用者這個欄位如果可以讓使用者透過 API 傳入是非常不合理的,如果可以任意修改那我就可以冒充任何人,所以他的值應該要自動的將目前登入的使用者存入。

讓我們編輯 server/app/todo/serializers.py 檔案

# ...... 以上省略 ......

class TaskSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True, read_only=True)
    tag_ids = serializers.PrimaryKeyRelatedField(
        allow_empty=False,
        write_only=True,
        many=True,
        queryset=todo_models.Tag.objects.all(),
        source="tags",
    )

    category = CategorySerializer(read_only=True)
    category_id = serializers.PrimaryKeyRelatedField(
        write_only=True,
        queryset=todo_models.Category.objects.all(),
        source="category",
    )

+   creator_id = serializers.ReadOnlyField()
+
    class Meta:
        model = todo_models.Task
-       fields = "__all__"
+       exclude = ("creator",)

# ...... 以下省略 ......

這邊我們把這個序列化從原本所有任務欄位,變成排除 creator 欄位,同時我們新增了一個唯讀的欄位名為 creator_id 他會顯示建立者的編號。

修改 ViewSet 自動設定建立者

現在大家可以試試使用 POST 方法請求 http://127.0.0.1:8000/api/todo/tasks 試著建立一個任務(記得啟動 server)

會發現他跟你說 creator 欄位是必填的,這是因為我們將這個欄位從序列化排除了,造成他存入失敗(因為 Model 要求這個欄位),現在讓我們編輯 server/app/todo/views.py 檔案

# ...... 以上省略 ......

class TaskViewSet(viewsets.ModelViewSet):
    queryset = (
        todo_models.Task.objects.order_by("id")
        .select_related("category")
        .prefetch_related("tags")
    )
    serializer_class = todo_serializers.TaskSerializer
    ordering_fields = ("id", "title")
    search_fields = ("title", "description")
    filterset_fields = {
        "is_finish": ("exact",),
        "tags__name": ("exact",),
        "id": ("gt", "gte", "lt", "lte"),
        "title": ("contains", "icontains"),
    }

    def get_serializer_class(self):
        if self.action == "create":
            return todo_serializers.TaskCreateSerializer

        return super().get_serializer_class()

+   def perform_create(self, serializer):
+       serializer.save(creator=self.request.user)

    @decorators.action(methods=["patch"], detail=True)
    def status(self, request, pk):
        task = self.get_object()

        serializer = self.get_serializer(
            task,
            data={"is_finish": not task.is_finish},
            partial=True,
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return response.Response(serializer.data)

# ...... 以下省略 ......

這邊我們新增了一個 perform_create 方法,這個方法會在新增時被 ViewSet 呼叫,他做的事情就是單純的序列化存起來而已,那這邊我們就是告訴他說當你存序列化時多給他一個資料也就是建立者(creator)為當前使用者(self.request.user)。

接著大家是再試著呼叫新增 API 看看,應該會發現我們可以順利建立了,而且 creator 會是當前登入的使用著,大家可以到 Admin 系統多建立幾個使用著測試一下。

修改 ViewSet 只顯示特定的任務

現在讓我們編輯 server/app/todo/views.py 檔案


class TaskViewSet(viewsets.ModelViewSet):
    queryset = (
        todo_models.Task.objects.order_by("id")
        .select_related("category")
        .prefetch_related("tags")
    )
    serializer_class = todo_serializers.TaskSerializer
    ordering_fields = ("id", "title")
    search_fields = ("title", "description")
    filterset_fields = {
        "is_finish": ("exact",),
        "tags__name": ("exact",),
        "id": ("gt", "gte", "lt", "lte"),
        "title": ("contains", "icontains"),
    }

+   def get_queryset(self):
+       queryset = super().get_queryset()
+
+       if not (
+           getattr(self.request.user, "is_staff", False)
+           or getattr(self.request.user, "is_superuser", False)
+       ):
+           return queryset.filter(creator=self.request.user)
+
+       return queryset
+
    def get_serializer_class(self):
        if self.action == "create":
            return todo_serializers.TaskCreateSerializer

        return super().get_serializer_class()

這邊我們做的事情是新增一個 get_queryset 方法,他與我們之前做的 get_serializer_class 方法類似,原本 ViewSet 的行為是回傳設定的 queryset,但這邊我們加上了判斷如果他是管理者(is_staff)或是超級使用者(is_superuser)就可以得到全部的任務,但如果不是就只能拿到自己的任務。

P.S. 這邊大家可能會發現我使用 getattr(self.request.user, "is_staff", False) 取得是否是管理員(超級使用者也是)而不是直接存取屬性例如 self.request.user.is_staff 是因為我們前面有提過 User 是可以被替換的,如果有被替換不保證會有這些屬性,所以使用 getattr 這個方式會是更好一些的,但當然如果你確定一定有這個屬性,也可以直接存取。

現在大家可以試試使用 GET 方法請求 http://127.0.0.1:8000/api/todo/tasks 應該會看到如果你使用超級使用者會看到所有任務,但如果使用一般使用者(在 Admin 系統可以建立)就只會拿到自己建立的任務。之前我們有提到 queryset 決定的事這個 ViewSet 可以修改的資料範圍,目前我們已經將不是自己建立的任務過濾掉了,所以我們也會無法修改它,大家可以呼叫 API 試試看。

結語

今天我們在任務 Model 中新增了一個建立者欄位,並且將舊資料關聯到「未知」使用者。同時我們修改了序列化與 ViewSet 讓他們自動的把目前登入的使用者當成建立者存入資料庫。同時限制只能看到修改刪除自己建立的使用者,但如果是超級使用者或管理者則沒有這個限制。

結束前別忘了檢查一下今天的程式碼有沒有問題,並排版好喔。

ruff check --fix .
black .
pyright .

今天的內容就到這邊了,讓我們期待明天的內容吧。

P.S. 今天的檔案更新可以參考我的 Git Commit 大家可以搭配服用


上一篇
Day27 - 檔案上傳
下一篇
Day29 - CORS 跨域資源共用
系列文
Django REST 大冒險:探索精彩紛呈的 API 開發世界30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言