實戰:日誌

接下來我們要建立個人數位助理的網站專案,在這個專案中有 2 個獨立的功能:日誌以及記帳。

建立專案與應用程式

建立新專案

在終端機中下達指令建立一個新的專案

django-admin.py startproject assistant

打完指令後,會產生一個 assistant 的資料夾

assistant/
    manage.py
    assistant/
        __init__.py
        settings.py
        urls.py
        wsgi.py

在專案下建立應用程式

cd assistant python manage.py startapp journal

修改專案設定檔 assistant/assistant/settings.py,新增第 34 行,修改第 28, 58, 107, 109 行

ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ 'journal', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ]
TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': ['templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ]
# Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ LANGUAGE_CODE = 'zh-hant' TIME_ZONE = 'Asia/Taipei'

資料庫

修改 assistant/web/models.py,新增第 4 - 10 行,定義日誌資料表:

from django.db import models # Create your models here. # 日誌 class Journal(models.Model): content = models.TextField("內容") created = models.DateField(auto_now_add=True) def __str__(self): return self.content

執行以下指令建立資料庫:

python manage.py makemigrations python manage.py migrate python manage.py createsuperuser

開啟網站服務

python manage.py runserver 0.0.0.0:80

網址、視圖、範本、表單

網址

開啟 assistant/assistant/urls.py,修改為以下程式碼:

from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView urlpatterns = [ path('admin/', admin.site.urls), path('', RedirectView.as_view(url='journal/')), path('journal', include('journal.urls')), ]

新增檔案 assistant/journal/urls.py

from django.urls import path from .views import * urlpatterns = [ path('', JournalList.as_view(), name="journal_list"), path('create/', JournalCreate.as_view()), path('<int:pk>/update/', JournalUpdate.as_view()), path('<int:pk>/delete/', JournalDelete.as_view()), ]

:bulb: urls.py 裡的 include() 的作用是什麼?

如上所示,我在們 assistant/journal/urls.py 中定義了應用程式 journal 的 4 條路徑規則:

路徑 對應功能
日誌列表
create/ 新增日誌
<int:pk>/update/ 修改日誌
<int:pk>/delete/ 刪除日誌

又在 assistant/assistant/urls.py 定義網站專案的路徑規則中,以第 23 行 程式碼:

path('journal/', include('journal.urls')),

將應用程式 journal 定義的路徑規則以前置 journal/ 字串的方式加入網站專案中,等同於在專案的路徑規則中加入以下 4 條規則:

路徑 對應功能
journal/ 日誌列表
journal/create/ 新增日誌
journal/<int:pk>/update/ 修改日誌
journal/<int:pk>/delete/ 刪除日誌

視圖

開啟 assistant/journal/views.py,修改為以下程式碼:

from django.shortcuts import render from django.views.generic import ListView, CreateView, UpdateView, DeleteView from .models import Journal # Create your views here. # 日誌列表 class JournalList(ListView): model = Journal ordering = ['-id'] # 依 id 欄位反向排序(新的在前面) # 新增日誌 class JournalCreate(CreateView): model = Journal fields = ['content'] # 自動產生表單時僅顯示 content 欄位 success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'form.html' # 修改日誌 class JournalUpdate(UpdateView): model = Journal fields = ['content'] # 自動產生表單時僅顯示 content 欄位 success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'form.html' # 刪除日誌 class JournalDelete(DeleteView): model = Journal success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'confirm_delete.html'

範本

為了將所有的頁面範本集中管理,前面在專案設定檔中已經指定了頁面範本的搜尋路徑,接下來我們就將所有的頁面範本都放在這個資料夾下。

建立頁面範本資料夾

新增頁面範本資料夾 assistant/templates

建立 journal 應用程式的頁面範本資料夾 assistant/templates/journal

新增頁面範本

新增網站基底頁面範本 assistant/templates/base.html

<!DOCTYPE html> <html lang="zh-hant"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>數位助理</title> </head> <body> <h1>數位助理</h1> <div> <a href="/journal/">日誌列表</a> <a href="/journal/create/">寫日誌</a> </div> <div>{% block content %}{% endblock %}</div> </body> </html>

新增日誌列表頁面範本 assistant/templates/journal/journal_list.html

{% extends 'base.html' %} {% block content %} <h2>我的日誌:</h2> <table> <tr> <th>時間</th> <th>項目</th> <th>操作</th> </tr> {% for journal in journal_list %} <tr> <td>{{ journal.created|date:"l" }}{{ journal.created }}</td> <td><a href="{{ journal.id }}/update/">{{ journal.content }}</a></td> <td><a href="{{ journal.id }}/delete/">刪除</a></td> </tr> {% endfor %} </table> {% endblock %}

:bulb: 關於 date 過濾器

它是用來處理日期/時間資料的過濾器,依據使用者指定的格式輸出。以下摘列 date 過濾器接受的部份常用格式字元及其代表意義:

格式字元 說明 範例輸出
d 月份的第幾天,以前置 0 補滿 2 位 ‘01’ 到 ‘31’
j 月份的第幾天,無前置 0 ‘1’ 到 ‘31’
D 短文字型式的星期幾 Fri
l 長文字型式的星期幾 Friday
w 數字型式的星期幾 ‘0’(星期天) 到 ‘6’(星期六)
z 年份中的第幾天,由 0 起算 ‘0’ 到 ‘365’
W ISO-8601標準中的週次,以星期一為一週起始 ‘1’ 到 ‘53’
m 月份,以前置 0 補滿 2 位 ‘01’ 到 ‘12’
n 月份,無前置 0 ‘1’ 到 ‘12’
M 短文字型式的月份名 ‘Jan’
b 短文字型式的月份名,全小寫 ‘feb’
F 長文字型式的月份名 ‘September’
t 當月份的總日數 ‘28’ 到 ‘31’
y 2位型式的西元年份 ‘19’
Y 4位型式的西元年份 ‘2019’
L 當年是否為閏年 ‘True’ 或 ‘False’
g 12小時制的小時,無前置 0 ‘1’ 到 ‘12’
G 24小時制的小時,無前置 0 ‘0’ 到 ‘23’
h 12小時制的小時,以前置 0 補滿 2 位 ‘01’ 到 ‘12’
H 24小時制的小時,以前置 0 補滿 2 位 ‘00’ 到 ‘23’
i 分鐘,以前置 0 補滿 2 位 ‘00’ 到 ‘59’
s 秒數,以前置 0 補滿 2 位 ‘00’ 到 ‘59’

注意,在正體中文語系下:

完整列表請參見官方網站說明文件:
https://docs.djangoproject.com/en/2.1/ref/templates/builtins/#date

新增共用編輯表單頁面範本 assistant/templates/form.html

{% extends 'base.html' %} {% block content %} <form action="" method="post"> {% csrf_token %} <table> {{ form.as_table }} </table> <input type="submit" value="送出" /> </form> {% endblock %}

新增共用刪除表單頁面範本 assistant/templates/confirm_delete.html

{% extends 'base.html' %} {% block content %} <h2>刪除紀錄</h2> <p>{{ object }}</p> <p>確定要刪除這筆記錄嗎?</p> <form action="" method="post"> {% csrf_token %} <input type="submit" value="是的,我要刪除" /> </form> {% endblock %}

使用者登入與登出

新增登入登出路徑規則

開啟 assistant/assistant/urls.py,新增以下程式碼:

urlpatterns = [ path('admin/', admin.site.urls), path('', RedirectView.as_view(url='journal/')), path('journal/', include('journal.urls')), path('accounts/', include('django.contrib.auth.urls')), ]

:bulb: 直接利用內建的應用程式 django.contrib.auth 來處理使用者登入與登出。

開啟專案設定檔 assistant/assistant/settings.py,增加以下程式碼:

# Redirect to home URL after login (Default redirects to /accounts/profile/) LOGIN_REDIRECT_URL = '/' # 設定登人後導向的頁面

登入登出頁面範本

新增資料夾 assistant/templates/registration

新增登入頁面範本 assistant/templates/registration/login.html

{% extends "base.html" %} {% block content %} {% if form.errors %} <p>帳號或密碼不符合,請再試一次。</p> {% endif %} <form action="" method="post"> {% csrf_token %} <div> <td>帳號:</td> <td>{{ form.username }}</td> </div> <div> <td>密碼:</td> <td>{{ form.password }}</td> </div> <div> <input type="submit" value="登入" /> </div> </form> {% endblock %}

新增登出頁面範本 assistant/templates/registration/logged_out.html

{% extends "base.html" %} {% block content %} <p>您已登出!!</p> <a href="{% url 'login'%}">請按此處重新登入</a> {% endblock %}

修改網站基底頁面範本 assistant/templates/base.htm,加上登出/登入連結:

<!DOCTYPE html> <html lang="zh-hant"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>數位助理</title> </head> <body> <h1>數位助理</h1> <div> <a href="/journal/">日誌列表</a> <a href="/journal/create/">寫日誌</a> {% if user.is_authenticated %} {{ user.username }} <a href="{% url 'logout' %}">登出</a> {% else %} <a href="{% url 'login' %}">登入</a> {% endif %} </div> <div>{% block content %}{% endblock %}</div> </body> </html>


限制登入後才能執行日記功能

到目前為止,雖然提供了使用者登人與登出的功能,但實際上使用者登入與否並不影響他在這個網站上所能進行的操作,即使在沒登入的狀態也能寫日誌、修改日誌、甚至刪除日誌。

你可能會想到在可以頁面範本中檢查使用者的登入狀況,再決定是否要顯示相關的連結。但是,看不到連結,不代表不能手動在網址列打上這些功能的存取路徑,一旦使用者「猜」到他想執行的操作所對應到的路徑,他可以自己打在網址列上,再讓瀏覽器送出存取請求來執行他想進行的動作。

為了做好網站的權限管控,不能只把不讓使用者操作的連結藏起來就好,而是應該在處理使用者請求執行某項功能的時候,要同時檢核他的權限。

這個範例的需求比較簡單,因為它是個人用的數位助理,所以只要檢查使用者是否已登入就好,不需要更複雜的權限管理。

開啟 assistant/journal/views.py,修改為以下程式碼:

from django.shortcuts import render from django.views.generic import ListView, CreateView, UpdateView, DeleteView from .models import Journal from django.contrib.auth.mixins import LoginRequiredMixin # Create your views here. # 日誌列表 class JournalList(LoginRequiredMixin, ListView): model = Journal ordering = ['-id'] # 依 id 欄位反向排序(新的在前面) # 新增日誌 class JournalCreate(LoginRequiredMixin, CreateView): model = Journal fields = ['content'] # 自動產生表單時僅顯示 content 欄位 success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'form.html' # 修改日誌 class JournalUpdate(LoginRequiredMixin, UpdateView): model = Journal fields = ['content'] # 自動產生表單時僅顯示 content 欄位 success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'form.html' # 刪除日誌 class JournalDelete(LoginRequiredMixin, DeleteView): model = Journal success_url = '/journal/' # 操作成功後重新導向日誌列表頁面 template_name = 'confirm_delete.html'

修改完成後,若在未登入的情況下,不論是點按日誌列表,或是寫日誌,亦或是直接在網址列登打修改某篇日誌或刪除某篇日誌的路徑,都會直接被轉址到要求使用者登入的頁面,待登入成功後才能繼續原先欲操作的功能:

分頁顯示

在日誌列表的頁面上,會將所有的日誌記錄全部列出來,但當日誌經過一段時間的累積之後,在同一個頁面上要顯示所有的日誌,有可能會因為數量太大,而導致頁面產生的速度過慢。因此一般的網站在處理清單式的頁面的時候,通常都會採用分頁的手法,將所有紀錄拆分成多頁顯示,每一項最多僅顯示固定筆數的紀錄。

修改視圖,加入分頁設定

開啟 assistant/journal/views.py,修改 JournalList 類別,新增第 13 行,通用視圖 ListView 的衍生類別可以透過指定 paginate_by 屬性來指定每頁顯示的最多資料筆數:

# 日誌列表 class JournalList(LoginRequiredMixin, ListView): model = Journal ordering = ['-id'] # 依 id 欄位反向排序(新的在前面) paginate_by = 3 # 設定每頁最多顯示的資料筆數

註:為了便於示範,在不產生過多測試資料的情況下,這裡設定每頁最多只顯示 3 筆日誌。實務上每頁顯示的資料量應該會更多。

處理頁面範本,加入分頁連結

在視圖中指定 paginate_by 屬性值,ListView 會自動依據這個設定取出目前頁面的資料量。因此頁面範本中只能取得最多 paginate_by 筆數的紀錄,但在頁面上顯示這些紀錄後,我們目前的頁面範本並不會自動顯示上一頁、下一頁、或其他分頁頁碼的連結。這部份不是 ListView 的工作,頁面如何呈現,該呈現什麼,這是頁面範本的任務。因此需要修改頁面範本,來規範這些分頁的連結該出現在何處,以及如何呈現這些分頁連結。

建立分頁連結範本

首先新增分頁連結的範本 assistant/templates/pagination.html

{% if is_paginated %} <div> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">上一頁</a> {% endif %} {% for page in paginator.page_range %} {% if page == page_obj.number %} <a href="#"><font color="red">{{ page }}</font></a> {% else %} <a href="?page={{ page }}">{{ page }}</a> {% endif %} {% endfor %} {% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}">下一頁</a> {% endif %} </div> {% endif %}

這個分頁連結的範本所產生的 HTML 碼並不是一個完整的頁面,僅產生分頁連結的部份而已。若有其他的頁面範本需要產生分頁連結的時候,可以在頁面範本中透過底下的標籤來引用分頁連結範本:

{% include 'pagination.html' %}

將分頁連結加入日誌列表

修改日誌列表頁面範本 assistant/templates/journal/journal_list.html,新增第 19 行,引入 pagination.html 分頁連結範本:

{% extends 'base.html' %} {% block content %} <h2>我的日誌</h2> <table> <tr> <th>時間</th> <th>項目</th> <th>操作</th> </tr> {% for journal in journal_list %} <tr> <td>{{ journal.created|date:"l" }}{{ journal.created }}</td> <td><a href="{{ journal.id }}/update/">{{ journal.content }}</a></td> <td><a href="{{ journal.id }}/delete/">刪除</a></td> </tr> {% endfor %} </table> {% include 'pagination.html' %} {% endblock %}