Django 收藏功能的實現:使用 ManyToMany 關係與自定義中介模型

更新日期: 2024 年 12 月 1 日

在本教學中,我們將介紹如何實現收藏功能,並通過 Django 的 ManyToManyField 和自定義中介模型,完成對收藏狀態的添加與刪除功能。

同時,我們還將優化視圖與模板,實現動態樣式切換,提升用戶體驗。

基礎關係回顧

在 Django 中,模型之間的關係主要包括:

  1. 一對多關係:一個模型的實例可以關聯到多個其他模型的實例。
  2. 多對多關係:兩個模型的實例可以互相多對多關聯。

一對多關係

範例:User 與 Resume

在一對多關係中,一個 User 可以有多個 Resume,但每個 Resume 只能屬於一個 User。

User  ===== has many ====> Resumes
           <------------------ resumes.user_id (ForeignKey)

範例:Resume 與 Comment

同樣,一個 Resume 可以有多個 Comment,但每個 Comment 只能屬於一個 Resume。

Resume  ===== has many ====> Comments
           <------------------ comments.resume_id (ForeignKey)

多對多關係

當兩個模型的關係是「雙向多對多」時,例如:

  • 一個 User 可以收藏多個 Resume。
  • 一個 Resume 也可以被多個 User 收藏。

這種情況需要用到多對多關係


多對多的數據庫設計

在多對多關係中,通常需要引入一個「第三個表格」,用來記錄兩個模型的關聯。

這個表格被稱為連接表(Join Table)

多對多結構

以下是多對多關係的結構示意:

User  ====== has many ======> Resume
User  <--------------------> Join Table <--------------------> Resume

連接表會記錄兩邊的關聯,例如:

  • user_id:指向 User 的主鍵。
  • resume_id:指向 Resume 的主鍵。

設置收藏模型與數據庫

新增中介模型

定義一個中介模型 FavoriteResume,用於管理 UserResume 的多對多關係:

檔案位置:resumes/models.py

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

class Resume(models.Model):
    title = models.CharField(max_length=100)
    skill = models.CharField(max_length=300, null=True)
    location = models.CharField(max_length=200, null=True)
    content = models.TextField(null=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)  # 履歷擁有者
    favorite_users = models.ManyToManyField(
        User,
        related_name="favorited_resumes",  # 反向查詢名稱
        through="FavoriteResume",  # 中介模型
        through_fields=("resume", "user"),  # 定義中介模型的關聯字段順序
    )

    def __str__(self):
        return f"{self.title} ({self.skill})"

class FavoriteResume(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)  # 收藏者
    resume = models.ForeignKey("Resume", on_delete=models.CASCADE)  # 被收藏的履歷
    created_at = models.DateTimeField(auto_now_add=True)  # 收藏時間

多對多關係的模型設計與說明

在 Django 中,多對多關係 是指一個模型的實例可以與另一個模型的多個實例相關聯,反之亦然。以下代碼設計展示了如何使用 ManyToManyField 和中介模型實現履歷的收藏功能。


模型代碼與說明

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

class Resume(models.Model):
    title = models.CharField(max_length=100)  # 履歷標題
    skill = models.CharField(max_length=300, null=True)  # 技能描述
    location = models.CharField(max_length=200, null=True)  # 工作地點
    content = models.TextField(null=True)  # 詳細內容
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)  # 履歷的擁有者
    
    # 多對多關聯字段,使用中介模型 "FavoriteResume"
    favorite_users = models.ManyToManyField(
        User,
        related_name="favorited_resumes",  # 反向查詢名稱
        through="FavoriteResume",  # 指定中介模型
        through_fields=("resume", "user"),  # 定義中介模型的字段順序
    )

    def __str__(self):
        return f"{self.title} ({self.skill})"

class FavoriteResume(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)  # 收藏者
    resume = models.ForeignKey("Resume", on_delete=models.CASCADE)  # 被收藏的履歷
    created_at = models.DateTimeField(auto_now_add=True)  # 收藏的時間

關鍵概念解析

在本設計中,使用了 FavoriteResume 作為中介模型,將 ResumeUser 模型關聯起來,並記錄額外信息。

  • 中介模型 FavoriteResume 的結構:
    • 包含兩個外鍵,分別指向 UserResume
    • 額外字段 created_at 用於記錄用戶收藏履歷的時間。
    • 中介模型不僅儲存關係,還能儲存更多信息,這是直接使用 ManyToManyField 所無法實現的。

ManyToManyFieldthrough 關鍵字的解析

  1. ManyToManyField 的功能:
    • Resume 模型中定義 favorite_users 字段,表示某份履歷被哪些用戶收藏。
    • 使用 through="FavoriteResume" 指定中介模型。
  2. through 的作用:
    • 通知 Django 使用 FavoriteResume 表作為多對多關係的橋樑。
    • through_fields 指定中介模型字段的關聯順序:("resume", "user")
  3. 為什麼 User 模型中未設置 through
    • 一般情況下,若要建立兩張表的多對多關係,兩張表都需要設置 through
    • 然而,這裡通過在 Resume 模型中設置 related_name="favorited_resumes",讓 User 模型能以反向查詢的方式實現關聯,避免了在內建 User 模型中額外設置。

related_name 的功能

  • 反向查詢的關聯:
    • resume.favorite_users.all():從履歷的角度查詢所有收藏該履歷的用戶。
    • user.favorited_resumes.all():從用戶的角度查詢用戶收藏的所有履歷。
  • 查詢的簡化:
    • 通過 related_name 直接建立反向查詢名稱,無需在每次查詢時明確指定字段或表。

設計後的功能舉例

  1. 正向查詢:
resume = Resume.objects.get(id=1)
users = resume.favorite_users.all()  # 獲取收藏該履歷的所有用戶
  1. 反向查詢:
user = User.objects.get(id=1)
resumes = user.favorited_resumes.all()  # 獲取該用戶收藏的所有履歷
  1. 新增收藏:
user = User.objects.get(id=1)
resume = Resume.objects.get(id=1)
FavoriteResume.objects.create(user=user, resume=resume)  # 手動創建記錄
  1. 刪除收藏:
FavoriteResume.objects.filter(user=user, resume=resume).delete()

遷移數據庫

執行以下命令,生成並應用遷移:

python manage.py makemigrations
python manage.py migrate

遷移與數據庫操作

  1. 執行遷移命令:
    • 在新增模型後,需要執行以下命令生成數據庫表:
python manage.py makemigrations
python manage.py migrate
  1. 為什麼 ManyToManyField 不會直接再 Resumes 資料庫中新增字段?
    • ManyToManyField 通常不會在主表中新增字段,而是通過第三張表(如 FavoriteResume)存儲關聯數據。
    • 這裡的 favorite_users 實際上是一個代理,Django 使用它來操作 FavoriteResume 表。

設置收藏功能的基礎路徑與視圖

新增 URL 路徑

resumes/urls.py 中,為收藏功能新增路徑 like

from django.urls import path
from . import views

app_name = "resumes"

urlpatterns = [
    # 其他路徑...
    path("<int:id>/like", views.like, name="like"),  # 新增收藏路徑
]

定義 like 函數

resumes/views.py 中,定義處理收藏的邏輯。

使用 @require_POST 確保只接受 POST 請求,並使用 @login_required 確保用戶已登入:

from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.shortcuts import get_object_or_404, redirect
from .models import Resume, FavoriteResume

@require_POST
@login_required
def like(request, id):
    resume = get_object_or_404(Resume, id=id)
    # 檢查是否已收藏
    favorite = resume.favorite_users.filter(id=request.user.id).first()

    if favorite:
        # 已收藏:刪除收藏
        FavoriteResume.objects.get(user=request.user, resume=resume).delete()
    else:
        # 未收藏:新增收藏
        FavoriteResume(user=request.user, resume=resume).save()

    return redirect("resumes:show", id=resume.id)

在收藏功能中,我們需要檢查當前用戶是否已經收藏了特定的履歷:

favorite = resume.favorite_users.filter(id=request.user.id).first()
  • 含義
    • 使用 filter(id=request.user.id)resume.favorite_users 的查詢集中,找到當前用戶的收藏記錄。
    • 如果有結果,first() 返回第一個記錄(即 FavoriteResume 中的一行數據)。
    • 如果查詢集為空,first() 返回 None,不會引發錯誤。
  • 後續邏輯
    • 如果 favorite 不為空,則說明用戶已經收藏該履歷,此時刪除收藏記錄。
    • 如果 favoriteNone,則新增一條收藏記錄。

補充說明:為什麼使用 .first() 而不是 [0]

filter() 方法的返回值

  • Django 的 filter() 方法會返回一個查詢集(QuerySet)
  • 這是一個類似列表的對象,包含所有符合過濾條件的記錄。
  • 即使查詢集結果為空,也不會引發錯誤,因為空查詢集仍然是合法的對象。

.first() 的作用

  • first() 方法會從查詢集中返回第一個結果,如果查詢集為空,則返回 None
  • 適合用於檢查「是否有結果」的情況。
  • 使用 .first() 的安全性:
    • 若查詢集為空,first() 返回 None,程式可以繼續執行。
    • 避免了直接使用索引(如 [0])可能導致的 IndexError

為什麼不能使用 [0]

  • 當查詢集為空時,試圖訪問 [0] 會觸發 IndexError,導致程式崩潰。
  • .first() 不同,[0] 不會自動處理空查詢集的情況,因此需要額外檢查是否有結果。

效果比較

方法行為描述安全性
.first()返回第一個結果,若查詢集為空則返回 None安全,無需額外檢查空查詢集
[0]返回第一個結果,若查詢集為空則引發 IndexError不安全,需要額外檢查

因此,使用 .first() 是更安全且簡潔的選擇,適合處理可能為空的查詢集情況。


模板邏輯與樣式更新

傳遞收藏狀態到模板

修改 show 函數,將當前用戶的收藏狀態傳遞到模板:

from django.shortcuts import render

def show(request, id):
    resume = get_object_or_404(Resume, id=id)
    favorited = resume.favorite_users.filter(id=request.user.id).first()  # 收藏狀態
    comments = resume.comment_set.all()

    return render(
        request,
        "resumes/show.html",
        {
            "resume": resume,
            "comments": comments,
            "favorited": favorited,  # 傳遞收藏狀態
        },
    )

引入 SVG 資源

搜尋與準備 SVG 圖檔

我們將為收藏功能設計兩種愛心圖案:

  • 實心愛心:代表「已收藏」狀態。
  • 空心愛心:代表「未收藏」狀態。

將下載的 heart-on.svgheart-off.svg 存放於 static/svg 資料夾中。

認識 SVG

SVG(Scalable Vector Graphics)是一種基於 XML 的圖形格式。它本質上是文字檔案,透過特殊的 HTML 組成圖形,具有以下特點:

  • 支援縮放而不失真。
  • 可直接內嵌在 HTML 或通過 <img> 引用。

更新 show.html

resumes/templates/resumes/show.html 中,添加收藏按鈕並顯示收藏狀態:

<form method="POST" action="{% url 'resumes:like' resume.id %}">
  {% csrf_token %}
  <button class="btn btn-sm">
    {% if favorited %}
      <img src="/static/svg/heart-on.svg" alt="已收藏" />
    {% else %}
      <img src="/static/svg/heart-off.svg" alt="未收藏" />
    {% endif %}
  </button>
</form>

代碼解析

  • method="post":設置表單為 POST 請求,符合收藏功能的需求。
  • {% csrf_token %}:添加 CSRF 保護。
  • action="{% url 'resumes:like' resume.id %}":設置表單提交的路徑。

小結

  1. 設置收藏功能的路由與視圖:確保使用 POST 請求,並檢查用戶是否登入。
  2. 使用自定義中介模型:提升關聯數據的靈活性與可擴展性。
  3. 優化模板交互:根據收藏狀態動態切換按鈕樣式,提供更好的用戶體驗。

通過以上步驟,我們成功實現了 Django 中的收藏功能,並運用了 ManyToManyField 與中介模型的進階功能。

Similar Posts

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *