Django -- 從平凡到超凡

第 13 章    按讚與留言

(1) 使用者閱讀文章時可按讚

新增功能:使用者閱讀文章時,如果喜歡這篇文章就可以按讚

∗ Model 增加欄位

▸ 在 Article model 新增 likes 欄位,儲存按讚者的帳號

article/models.py
from django.db import models
from django.contrib.auth.models import User


class Article(models.Model):
    ...
    pubDateTime = models.DateTimeField(auto_now_add=True)
    likes = models.ManyToManyField(User)
✶ 首先匯入 User
ManyToManyField(User):多對多欄位,亦即許多人可以對同一篇文章按讚, 同一個人也可以對許多篇文章按讚

▸ 執行資料庫遷移:makemigrations, migrate

∗ 規劃 URL 對應

▸ 規劃使用者按讚的 URL 格式為 article/articleLike/<articleId>/, 其中 articleId 是文章物件在資料庫中的 id

article/urls.py
...

urlpatterns = [
    ...
    url(r'^articleSearch/$', views.articleSearch, name='articleSearch'),
    url(r'^articleLike/(?P<articleId>[0-9]+)/$', views.articleLike, name='articleLike'),
]

∗ 撰寫 view 程式

▸ 使用者按「讚」連結後,將使用者加入多對多欄位

article/views.py
...
def articleSearch(request):
    ...


def articleLike(request, articleId):
    '''
    Add the user to the 'likes' field:
        1. Get the article; redirect to 404 if not found
        2. If the user does not exist in the "likes" field, add him/her
        3. Finally, call articleRead() function to render the article
    '''
    article = get_object_or_404(Article, id=articleId)
    if request.user not in article.likes.all():
        article.likes.add(request.user)
    return articleRead(request, articleId)
✶ 利用 get_object_or_404() 函式取出文章物件
if request.user not in article.likes.all():判斷 user 是否不在文章的 likes 欄位裡 (沒按過讚)
# 如果使用者沒按過讚,利用 article.likes.add() 函式將使用者加入 likes 欄位裡
# article.likes:從 article 端存取 user, 例如 article.likes.all() 可將該文章所有按讚的使用者取出
# user.article_set:從 user 端存取 article,例如 user.article_set.all() 可將該使用者所有按讚的文章取出
✶ 最後呼叫 articleRead() 函式再次呈現文章
✶ 註:雖然會改變系統狀態 (亦即會寫入資料庫) 的請求應該使用 POST 方法,但因一位使用者最多只能加入一次, 影響不大,所以採用 GET 方法也不為過

∗ 修改範本並設定樣式

▸ 在部落格頁面,每篇文章在圖示 like 之後顯示按讚人數

article/templates/article/article.html
        ...
        <p>發表時間:{{ item.pubDateTime|date:'Y-m-d H:i' }}</p>
        <div class="articleContent">{{ item.content|linebreaks|truncatechars_html:30 }}</div>
        <p class=like>
          <img id=like src="{% static 'main/img/like.png' %}" alt="Like"> {{ item.likes.count }}
        </p>
      {% else %}
        ...
{{ item.likes.count }}:在 Django 範本中可以使用 .count 來計算多對多欄位的項目數量,亦即按讚的人數
✶ 加上樣式:設定按讚人數顏色及字體,影像上下置中對齊文字
article/static/article/css/article.css
...
.table-hover tr:hover td {
  background-color: #cdeaf0;
}

.like {
  font-weight: bold;
  color: #3e7bd1;
}

img#like {
  vertical-align: middle;
  width: 1.6em;
}
✶ 結果如下:
numLikes

▸ 在閱讀文章頁面,除顯示按讚人數外,再加上「讚」連結,讓使用者可以點擊

article/templates/article/articleRead.html
    ...
    <div class="articleContent">{{ article.content|linebreaks }}</div>
    <p class=like>
      <img id=like src="{% static 'main/img/like.png' %}" alt="Like"> {{ article.likes.count }}
      {% if user.is_authenticated %}
        <a href="{% url 'article:articleLike' article.id %}">讚</a>
      {% endif %}
    </p>
    {% for comment in comments %}
      ...
✶ 登入的使用者才看得到「讚」連結
✶ 結果如下:
numLikes
多對多關聯

資料庫管理的教科書會說:兩個資料表的多對多關聯其實是透過建立一個新的資料表來實現, 以上述的 ArticleUser 兩個 Model 而言, 應該建立一個名為 Like 的 Model 來實做多對多關聯, 此 Model 僅有兩個欄位,分別是 ArticleUser 的外來鍵,如下:

class Like(models.Model):
   article = models.ForeignKey(Article)
   User = models.ForeignKey(User)

熟悉資料庫規劃的讀者對此應該不陌生吧!

那麼,在 Django 的引擎蓋下 (Under the hood),多對多關聯是如何實做的呢?

不要感到太意外,就是如此做的 ;-)

(2) 顯示留言者

∗ 在留言左方顯示使用者名稱

▸ 要記錄留言者,需在 Comment model 新增 user 欄位, 是 User model 的外來鍵

article/models.py:
...


class Comment(models.Model):
    article = models.ForeignKey(Article)
    user = models.ForeignKey(User)
    content = models.CharField(max_length=128)
    ...

▸ 執行 makemigrations:因為是新增欄位,Postgres 要求輸入舊資料的欄位內容

You are trying to add a non-nullable field 'user' to comment without a default; we can't 
do that (the database needs something to populate existing rows).
Please select a fix:
  1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
  2) Quit, and let me add a default in models.py
Select an option: 1
--> 選擇統一設定為某個使用者
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>>
--> 此處需要設定外來鍵的資料:前往管理者頁面,點擊某個使用者並觀察 URL 欄位裡使用者的 id (例如: http://localhost:8000/admin/auth/user/4/change/), 然後回到此處輸入該數字,結果:
Migrations for 'article':
  article/migrations/0002_comment_user.py
    - Add field user to comment
Finished "/home/<username>/webapps/git/blog/blog/manage.py makemigrations" execution.

▸ 執行 migrate 完成資料庫遷移

Operations to perform:
  Apply all migrations: account, admin, article, auth, contenttypes, sessions
Running migrations:
  Applying article.0002_comment_user... OK
Finished "/home/<username>/webapps/git/blog/blog/manage.py migrate" execution.

∗ 修改填充程式

▸ 在第 7 章所建立的 admin 尚無對應的 UserProfile,現在建立:

populate/admin.py
...
from account.models import UserProfile


def populate(): 
    print('Creating admin account ... ', end='')
    User.objects.all().delete()
    admin = User.objects.create_superuser(username='admin', password='admin', email=None)
    UserProfile.objects.create(user=admin, fullName='管理者')
    print('done')
✶ 匯入 UserProfile
✶ 將新增的管理者帳號指派給 admin 變數
✶ 新增一筆 UserProfile Instance,設定 user=admin,及 fullName='管理者',其餘欄位空白

▸ 留言新增留言者

populate/article.py
...
from django.contrib.auth.models import User

...


def populate():
    ...
    Comment.objects.all().delete()
    admin = User.objects.first()
    for title in titles:
        ...
        for comment in comments:
            Comment.objects.create(article=article, user=admin, content=comment)
    print('done')

...
✶ 匯入 User
✶ 取出 User 資料表的第一筆資料 (就是管理員資料),並指派給 admin 變數
✶ 每筆 Comment Instance,都設定留言者為管理者:user=admin

▸ 重新執行填充程式

(blogVenv)$ cd <project>
(blogVenv)$ python -m populate.test
Creating admin account ... done
Populating Article and Comment ... done

∗ 文章範本:在留言左方加入留言者

article/templates/article/article.html:

...
      <p>
        <span class="commentAuthor">{{ item.user.profile.fullName }}</span>
        <span class="comment">{{ item.content }}</span><br>
        <span class="commentTime">{{ item.pubDateTime|date:'m月d日 H:i'}}</span>
      </p>
...
✶ 透過 profileuser 反向連至 userProfile,再取出 fullName 欄位

article/templates/article/articleRead.html:

...
  <div class="commentDiv">
    <span class="commentAuthor">{{ comment.user.profile.fullName }}</span>
    <span class=comment>{{ comment.content }}</span><br>
    <span class=commentTime>{{ comment.pubDateTime|date:'m月d日 H:i' }}</span>
  </div>
...

∗ 設定 CSS 樣式

article/static/article/css/article.css:

...

.commentDiv {
  margin-top: 1em;
}

.comment, .commentAuthor {
  font-size: 0.8em;
}

.commentAuthor {
  font-weight: bold;
  color: #186caf;
}

.commentTime {
  ...
}
...

--> 測試:閱讀一篇文章

commentAuthor

(3) 使用者在閱讀文章時可新增留言

∗ 規劃 URL 對應

▸ 規劃 URL 格式為 article/commentCreate/<articleId>/, 其中 articleId 是文章物件在資料庫中的 id

article/urls.py
...

urlpatterns = [
    ...
    url(r'^articleSearch/$', views.articleSearch, name='articleSearch'),
    url(r'^articleLike/(?P[0-9]+)/$', views.articleLike, name='articleLike'),
    
    url(r'^commentCreate/(?P<articleId>[0-9]+)/$', views.commentCreate, name='commentCreate'),
]

∗ 修改範本

▸ 如果使用者在登入的狀況下,在最後一筆留言之下建立留言表單

article/templates/article/articleRead.html
    ...
    {% for comment in comments %}
      ...
    {% endfor %}
    {% if user.is_authenticated %}
      <br>
      <form method="post" action="{% url 'article:commentCreate' article.id %}">
        {% csrf_token %}
        <input type="text" name="comment"  placeholder="留言 ...">
        <input class="btn" type="submit" value="送出">
      </form>
    {% endif %}
{% endblock %}

∗ 撰寫 view 程式

▸ 加入 commentCreate() 函式

article/views.py
def commentCreate(request, articleId):
    '''
    Create a comment for an article:
        1. Get the "comment" from the HTML form
        2. Store it to database
    '''
    if request.method == 'GET':
        return articleRead(request, articleId)
    # POST
    comment = request.POST.get('comment')
    if comment:
        comment = comment.strip()
    if not comment:
        return redirect('article:articleRead', articleId=articleId)
    article = get_object_or_404(Article, id=articleId)
    Comment.objects.create(article=article, user=request.user, content=comment)
    return redirect('article:articleRead', articleId=articleId)
if request.method ...:如果請求方法是 GET,呼叫 articleRead(...) 重新顯示此篇文章 (唉,使用者又來亂了!)
comment = request.POST.get('comment'):從 HTML 表單中擷取 comment 資料,並指派給變數 comment
if comment::如果使用者有輸入資料 (包括空白),利用 .strip() 函式刪除字串前後的空白
if not comment:如果 comment 是空字串,不儲存資料,轉址重新顯示該篇文章,完成 Post/redirect/get 機制
✶ 利用 get_object_or_404() 函式取出留言所屬文章
Comment.objects.create(...):建立一筆留言資料,並設定所屬文章、 留言者、及留言內容
return redirect(...):轉址顯示該篇文章,完成 Post/redirect/get 機制

∗ 測試

commentForm

(4) 使用者可以修改自己的留言

∗ 規劃 URL 對應

▸ 規劃 URL 格式為 article/commentUpdate/<commentId>/, 其中 commentId 是留言物件在資料庫中的 id

article/urls.py
...

urlpatterns = [
    ...
    url(r'^articleSearch/$', views.articleSearch, name='articleSearch'),
    
    url(r'^commentCreate/(?P<articleId>[0-9]+)/$', views.commentCreate, name='commentCreate'),
    url(r'^commentUpdate/(?P<commentId>[0-9]+)/$', views.commentUpdate, name='commentUpdate'),
]

∗ 修改範本

▸ 區分登入者和非登入者的留言

articleRead.html
    ...
    {% for comment in comments %}
      <div class=commentDiv>
        <span class="commentAuthor">{{ comment.user.profile.fullName }}</span>
        {% if comment.user != user %}
          <span class="comment">{{ comment.content }}</span>
        {% else %}
          <form class="inlineBlock" method="post" action="{% url 'article:commentUpdate' comment.id %}">
            {% csrf_token %}
            <input type="text" name="comment" value="{{ comment.content }}">
            <input class="btn" type="submit" value="修改">
          </form>
        {% endif %}<br>
        <span class="commentTime">{{ comment.pubDateTime|date:'Y-m-d H:i'}}</span>
      </div>
    {% endfor %}
    ...
✶ 如果留言者不是登入者,顯示留言內容
✶ 否則 (留言者就是登入者) 顯示一張表單,內含一個輸入框,其內容就是留言內容,而且可以讓使用者修改

∗ 撰寫 view 程式

▸ 加入 commentUpdate() 函式

article/views.py
def commentUpdate(request, commentId):
    '''
    Update a comment:
        1. Get the comment to update and its article; redirect to 404 if not found
        2. If comment is empty, delete the comment
        3. Else update the comment
    '''
    commentToUpdate = get_object_or_404(Comment, id=commentId)
    article = get_object_or_404(Article, id=commentToUpdate.article.id)
    if request.method == 'GET':
        return articleRead(request, article.id)
    # POST    
    comment = request.POST.get('comment', '').strip()
    if not comment:
        commentToUpdate.delete()
    else:
        commentToUpdate.content = comment
        commentToUpdate.save()
    return redirect('article:articleRead', articleId=article.id)
✶ 利用 get_object_or_404() 函式分別取出擬修改的留言及所屬文章
✶ 如果請求的方法是 GET (使用者又來亂了!),呼叫 articleRead() 重新顯示此文章, 並結束此函式
✶ 如果請求的方法是 POST,利用 request.POST.get() 函式從 HTML 表單擷取使用者所修改的留言資料,並且接續執行 .strip() 以刪除字串前後空白
--> 此處指定如果找不到變數,request.POST.get() 函式應回覆空字串而不是 None,以免呼叫 .strip() 函式時程式會當掉
✶ 如果留言資料非空字串,利用 .strip() 函式刪除字串前後空白
✶ 刪除前後空白後,如果是留言空字串 (亦即原始字串是一連串的空白),就刪除此份留言 (空白留言等於沒有留言)
✶ 否則修改留言內容後儲存
✶ 最後轉址重新顯示此文章,完成 Post/redirect/get 機制

∗ 測試 (登入者:test)

commentUpdate

(5) 使用者可以刪除自己的留言

∗ 規劃 URL 對應

▸ 規劃 URL 格式為 article/commentDelete/<commentId>/, 其中 commentId 是留言物件在資料庫中的 id

article/urls.py
...

urlpatterns = [
    ...
    url(r'^articleSearch/$', views.articleSearch, name='articleSearch'),
    
    url(r'^commentCreate/(?P<articleId>[0-9]+)/$', views.commentCreate, name='commentCreate'),
    url(r'^commentUpdate/(?P<commentId>[0-9]+)/$', views.commentUpdate, name='commentUpdate'),
    url(r'^commentDelete/(?P<commentId>[0-9]+)/$', views.commentDelete, name='commentDelete'),
]

∗ 修改範本

▸ 再加上刪除留言的 HTML 表單

articleRead.html
    ...
    {% for comment in comments %}
      <div class=commentDiv>
        <span class="commentAuthor">{{ comment.user.profile.fullName }}</span>
        {% if comment.user != user %}
          <span class="comment">{{ comment.content }}</span>
        {% else %}
          <form class="inlineBlock" method="post" action="{% url 'article:commentUpdate' comment.id %}">
            {% csrf_token %}
            <input type="text" name="comment" value="{{ comment.content }}">
            <input class="btn" type="submit" value="修改">
          </form>
          <form class="inlineBlock" method="post" action="{% url 'article:commentDelete' comment.id %}">
            {% csrf_token %}
            <input class="btn deleteConfirm" type="submit" value="刪除">
          </form>
        {% endif %}<br>
        <span class="commentTime">{{ comment.pubDateTime|date:'Y-m-d H:i'}}</span>
      </div>
    {% endfor %}
    ...
    
    {% endblock %}
{% block script %}
  <script src="{% static 'main/js/deleteConfirm.js' %}"></script>
{% endblock %}
✶ 如果留言者就是登入者,再加上一張刪除留言的表單,設定 class="inlineBlock" 讓刪除按鈕與修改按鈕並列,刪除按鈕加上 deleteConfirm 之 CSS 類別,因此, 使用者刪除留言時會出現確認刪除之對話框
✶ 最後加上匯入 deleteConfirm.js 之 JavaScript 程式檔

∗ 撰寫 view 程式

▸ 加入 commentUpdate() 函式

article/views.py
def commentDelete(request, commentId):
    '''
    Delete a comment:
        1. Get the comment to update and its article; redirect to 404 if not found
        2. Delete the comment
    '''
    comment = get_object_or_404(Comment, id=commentId)
    article = get_object_or_404(Article, id=comment.article.id)
    if request.method == 'GET':
        return articleRead(request, article.id)
    # POST
    comment.delete()
    return redirect('article:articleRead', articleId=article.id)
✶ 利用 get_object_or_404() 函式分別取出擬刪除的留言及所屬文章
✶ 如果請求的方法是 GET (使用者又來亂了!),呼叫 articleRead() 重新顯示此文章, 並結束此函式
✶ 如果請求的方法是 POST,刪除此留言
✶ 最後轉址重新顯示此文章,完成 Post/redirect/get 機制

∗ 測試 (登入者:test)

commentDelete

(6) 小結

∗ 上推專案到 Github

▸ Right click project --> Team --> Commit --> Commit message: Chapter 13 finished --> Commit and Push

∗ 練習

▸ 在 bookstore 專案中,嘗試建立多對多的關係,例如一本書可以在許多書店陳列, 一個書店也可以陳列許多書

▸ 查查看,article.likes 還可以串連哪些方法?例如:刪除某個按讚者,刪除除所有按讚者 ...

上一章       下一章