訊息管理

在專案中設計了讓使用者彼此傳訊息的機制,將這個機制稍微修改一下,也可以當成課程的公告。

定義資料模型

傳送與接收訊息比較偏向使用者個人的相關功能,所以就不另外建立新的應用程式,而是將先前的應用程式 user 加以擴充,因此選擇在資料模型定義在應用程式 user 之下。

開啟資料模型定義檔 user/models.py,內容如下:

from django.db.models import * from django.contrib.auth.models import User from course.models import Course class Message(Model): # 訊息 sender = ForeignKey(User, CASCADE, related_name='outbox') course = ForeignKey( Course, CASCADE, related_name='notices', null=True, default=None) recipient = ForeignKey( User, CASCADE, related_name='inbox', null=True, default=None) title = CharField('主旨', max_length=255) body = TextField('內容') created = DateTimeField('時間', auto_now_add=True) def __str__(self): return "{}: {}-{}".format( self.id, self.sender, self.title ) class MessageStatus(Model): # 訊息讀取紀錄 message = ForeignKey(Message, CASCADE, related_name='status') user = ForeignKey(User, CASCADE, related_name='read_list') read = DateTimeField('閱讀時間', auto_now_add=True) def __str__(self): return "{}: {}-{} @{}".format( self.id, self.message.sender, self.message.title, self.read )

這邊定義了兩個資料模型,Message 是用來儲存訊息或公告的內容,而 MessageStatus 則是用來記錄訊息或公告被相關使用者讀取的紀錄。

資料模型 Message 包含了 6 個欄位:

訊息讀取紀錄 MessageStatus 則是用來紀錄使用者在何時讀取過訊息,它包含以下欄位:

為何不將訊息讀取的時間直接在 Message 模型裡建一個欄位來紀錄就好?如果只用來處理使用者間的私訊的話,直接多建一個讀取時間的欄位其實就可以了。但現在我們想將訊息的機制也當成課程公告的話,假設課程裡有 50 個學生,每發一篇公告,就得把公告的紀錄複製 50 份,在收件者的欄位分別填入這 50 個學生,重複的資料量過多,因此把訊息或公告的內容與其被讀取與否的狀態分開成不同的資料模型,而且這樣的設計有額外的好處,在發送或公告訊息的時候,僅需處理訊息本身即可,當個別使用者讀取訊息時,再建立讀取紀錄即可。

定義完資料模型後,別忘了做異動腳本與資料庫遷移:

python manage.py makemigrations
python manage.py migrate

定義路徑規則

先開啟 user/urls.py 來定義私人訊息的路徑,修改第 1 行,插入第 4 - 10, 21 行

from django.urls import path, include from .views import * tmp_urlpatterns = [ path('', MsgList.as_view(), name='user_inbox'), path('outbox/', MsgOutbox.as_view(), name='user_outbox'), path('<int:mid>/', MsgRead.as_view(), name='user_msgread'), path('<int:mid>/reply/', MsgReply.as_view(), name='user_msgreply'), path('send/<int:rid>/', MsgSend.as_view(), name='user_msgsend'), ] urlpatterns = [ path('', include('django.contrib.auth.urls')), path('', UserDashboard.as_view(), name='user_dashboard'), path('list/', UserList.as_view(), name='user_list'), path('register/', UserRegister.as_view(), name='user_register'), path('<int:pk>/', UserView.as_view(), name='user_view'), path('<int:pk>/edit/', UserEdit.as_view(), name='user_edit'), path('<int:pk>/password/', UserPasswordUpdate.as_view(), name='user_password'), path('<int:uid>/teacher/', UserTeacherToggle.as_view(), name='user_teacher_toggle'), path('msg/', include(tmp_urlpatterns)), ]

這邊使用了一個特殊的技巧,先定義了訊息相關功能的路徑規則清單 tmp_urlpatterns,然後再以 include() 函式將其引入 urlpatterns 清單中並指定前綴字串。新增的 5 條路徑規則在專案中都是以 user/msg/ 開頭。

另外,也定義一下課程公告相關功能的路徑規則。開啟 course/urls.py,修改第 1 行,新增第 4 - 7, 17 行

from django.urls import path, include from .views import * msg_urlpatterns = [ path('', MsgList.as_view(), name='course_msglist'), path('broadcast/', MsgCreate.as_view(), name='course_msgbroadcast'), ] urlpatterns = [ path('', CourseList.as_view(), name='course_list'), path('create/', CourseCreate.as_view(), name='course_create'), path('<int:cid>/', CourseView.as_view(), name='course_view'), path('<int:cid>/edit/', CourseEdit.as_view(), name='course_edit'), path('<int:cid>/enroll/', CourseEnroll.as_view(), name='course_enroll'), path('<int:cid>/users/', CourseUsers.as_view(), name='course_users'), path('<int:cid>/seat/', CourseEnrollSeat.as_view(), name='course_seat'), path('<int:cid>/msg/', include(msg_urlpatterns)), ]

定義處理視圖

私訊

先來處理使用者之間的私訊,請開啟 user/views.py 依底下的說明方式修改:

from django.views.generic import * from django.urls import reverse_lazy from django.contrib.auth.mixins import * from django.contrib.auth.models import User, Group from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.db.models import Q, Subquery, OuterRef from django import forms from .models import * from django.contrib import messages

接著在同一個檔案新增以下幾個處理視圖。

收件匣
class MsgList(LoginRequiredMixin, ListView): extra_context = {'title': '收件匣'} paginated_by = 20 def get_queryset(self): user = self.request.user return Message.objects.annotate( read=Subquery( user.read_list.filter(message=OuterRef('id')).values('read')) ).filter( Q(recipient=user) | Q(course__in=user.enroll_set.values('course')) ).select_related('course', 'sender').order_by('-created')

收件匣裡的訊息除了私訊之外,也將所參與課程的公告一併列出顯示,所以 MsgList 類別繼承了 ListView 來處理清單列表的功能。由於不是簡單的列出所有的訊息,而是需要對訊息進行篩選只列出與使用者相關的訊息,因此覆寫了 get_queryset() 方法來回傳自訂的查詢組:

    - `MessageStatus` 模型裡有另一個 `ForeignKey` 型態的欄位 `course`,它紀錄著使用者已讀取訊息的編號。++第 135 行++針對 `user.read_list` 進行篩選的條件是,`message` 欄位的值要符合 `OuterRef('id')`,這邊的 `OuterRef()` 代表的是外層查詢的資料模型,在本例中指的是 `Message`,整個篩選條件的意思是說,使用者已讀取訊息的紀錄的 `message` 欄位的值需與外層的 `Message` 模型的紀錄的編號(`id`)相同。
    - 由於一個欄位只允許單一值,所以針對符合的讀取紀錄的 3 個欄位,透過 `values('read')` 指定僅回傳其讀取時間(`read`)欄位。
    - 如果訊息尚無讀取紀錄,則取到的值會是 `None`。
寄件匣
class MsgOutbox(LoginRequiredMixin, ListView): extra_context = {'title': '寄件匣'} def get_queryset(self): user = self.request.user return user.outbox.annotate( read=Subquery(user.read_list.filter( message=OuterRef('pk')).values('id')) ).select_related('course', 'recipient').order_by('-created')

使用者發送的訊息可以透過其 outbox 屬性取得,可以回頭翻一下 Message 資料模型的 sender 欄位的定義。

讀取訊息
class MsgRead(LoginRequiredMixin, DetailView): model = Message pk_url_kwarg = 'mid' def get_queryset(self): return super().get_queryset().select_related('course', 'sender') def get_object(self): msg = super().get_object() if not msg.status.filter(user=self.request.user).exists(): MessageStatus(message=msg, user=self.request.user).save() return msg

讀取訊息的時候,如果使用者是第一次讀取該訊息,則需新增 MessageStatus 紀錄。通用視圖 DetailView 的衍生類別可以透過覆寫 get_object() 方法來改變取得指定紀錄的方式,不過實際上在這邊只是透過覆寫這個方法來處理是否需要新增訊息讀取紀錄:

發送訊息
class MsgSend(LoginRequiredMixin, CreateView): extra_context = {'title': '傳送訊息'} fields = ['title', 'body'] model = Message def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['recipient'] = User.objects.get(id=self.kwargs['rid']) ctx['success_url'] = self.request.META.get('HTTP_REFERER', '/') return ctx def form_valid(self, form): form.instance.sender = self.request.user form.instance.recipient = User.objects.get(id=self.kwargs['rid']) return super().form_valid(form) def get_success_url(self): messages.add_message(self.request, messages.SUCCESS, '訊息已送出!') return self.request.POST.get('success_url')

傳送訊息的背後實際上是要建立一筆 Message 的紀錄,所以可以繼承 CreateView 來實作。

回覆訊息
class MsgReply(LoginRequiredMixin, CreateView): extra_context = {'title': '回覆訊息'} model = Message fields = ['title', 'body'] def get_initial(self): self.msg = Message.objects.get(id=self.kwargs['mid']) return { 'title': 'Re: '+self.msg.title, 'body': "{}({}) 於 {} 寫道:\n> {}".format( self.msg.sender.username, self.msg.sender.first_name, self.msg.created, "\n> ".join(self.msg.body.split('\n')) ), } def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['recipient'] = self.msg.sender ctx['success_url'] = self.request.META.get('HTTP_REFERER', '/') return ctx def form_valid(self, form): form.instance.sender = self.request.user form.instance.recipient = self.msg.sender return super().form_valid(form) def get_success_url(self): messages.add_message(self.request, messages.SUCCESS, '訊息已送出!') return self.request.POST.get('success_url')

在設計回覆訊息的路徑規則時,在路徑裡安排的參數指的是欲回覆的訊息的編號,透過編號取得訊息紀錄,就可以直接拿該訊息的送件者當成回覆訊息的收件者。我們另外仿照一般電子郵件的處理方式,把原訊息的內容每一行前面加上 > 表示引用的原訊息,以方便使用者針對訊息內容進行回覆。

課程公告

開啟 course/views.py,新增第 8, 9 行引用所需資源:

from django import forms from .models import * from django.db.models import Subquery, OuterRef from user.models import Message

接著在檔案最後,新增以下幾個處理視圖。

課程公告列表
class MsgList(CourseAccessMixin, ListView): # 公告列表 permission = COURSE_PERM_MEMBER extra_context = {'title': '公告列表'} model = Message paginate_by = 20 template_name = 'course/message_list.html' def get_queryset(self): return self.course.notices.annotate( read=Subquery(self.request.user.read_list.filter( message=OuterRef('id')).values('read')) ).order_by('-created')

因為想在公告列表的時候也標示哪些是未讀的公告,所以仿照私訊列表的技巧,以 annotate() 方法在結果中加入新欄位 read 來標示公告被目前使用者首次讀取的時間,如果 read 的結果是空的,表示該訊息尚未被目前使用者讀取過。

新增課程公告
class MsgCreate(CourseAccessMixin, CreateView): # 教師新增課程公告 permission = COURSE_PERM_TEACHER extra_context = {'title': '新增公告'} model = Message fields = ['title', 'body'] def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['success_url'] = self.request.META.get('HTTP_REFERER', '/') return ctx def form_valid(self, form): form.instance.course = self.course form.instance.sender = self.request.user return super().form_valid(form) def get_success_url(self): messages.add_message(self.request, messages.SUCCESS, '公告已張貼!') return self.request.POST.get('success_url')

教師張貼課程公告的處理方式與使用者發送私訊類似,但是在傳送額外的參數給頁面範本時要傳的是課只是在驗證表單時要填入的欄位是 course 而不是 recipient

提醒一下,這裡沒有指定 template_name 屬性,CreateView 預設會以預設的邏輯來取用頁面範本,所以它會使用的頁面範本為 templates/user/message_form.html 檔。

頁面範本

儀表板

修改檢視使用者範本 templates/user/dashboard.html,插入第 10 - 17 行

<div class="card-body"> {% if user == tuser %} {% with def_btn="btn btn-sm btn-primary" %} <div id="user-shortcuts" class="mb-2"> <a href="{% url 'user_inbox' %}" class="{{ def_btn }}">收件匣</a> <a href="{% url 'user_outbox' %}" class="{{ def_btn }}">寄件匣</a> </div> {% endwith %} {% endif %} <div class="card-title">選修的課程</div>

訊息列表

這個範本會被用在使用者收件匣、寄件匣,而在使用者的收件匣中,會列出使用者選修課程的課程公告以及與其他使用者間的私訊。

請新增收件匣訊息列表範本 templates/user/message_list.html

{% extends "base.html" %} {% block content %} <ul class="list-group"> {% for msg in message_list %} <li class="list-group-item d-md-flex"> <a href="{% url 'user_msgread' msg.id %}"> {% if msg.course %} <span class="badge badge-danger">課程公告</span> {% endif %} {{ msg.title }} {% if not msg.read %} <span class="badge badge-warning">New!</span> {% endif %} </a> <div class="ml-auto"> {% if msg.course %} <a href="{% url 'course_view' msg.course.id %}"> {{ msg.course.name }} </a> {% else %} {% if msg.sender == user %} <span> <i class="fas fa-arrow-circle-right"></i> {{ msg.recipient.username }}({{ msg.recipient.first_name }}) </span> {% else %} <span> <i class="fas fa-arrow-circle-left"></i> {{ msg.sender.username }}({{ msg.sender.first_name }}) </span> {% endif %} {% endif %} <small class="text-muted"> <i class="fas fa-clock"></i> {{ msg.created }} </small> </div> </li> {% endfor %} </ul> {% include "pagination.html" %} {% endblock %}

檢視訊息

建立檢視訊息範本檔 templates/user/message_detail.html,內容如下:

{% extends "base.html" %} {% block content %} <div id="message-card" class="card"> <div class="card-header d-md-flex"> <h4 class=""> {% if message.course %} <span class="badge badge-danger">課程公告</span> {% endif %} {{ message.title }} </h4> <div class="ml-auto"> <span>{{ message.sender.username }}( {{ message.sender.first_name }})</span> <small><i class="fas fa-clock"></i> {{ message.created }}</small> </div> </div> <div class="card-body"> {{ message.body|linebreaks}} </div> <div class="card-footer"> <a href="{% url 'user_inbox' %}" class="btn btn-sm btn-primary"> <i class="fas fa-inbox"></i> 收件匣 </a> {% if message.course %} <a href="{% url 'course_msglist' message.course.id %}" class="btn btn-sm btn-primary"> <i class="fas fa-list"></i> 課程公告 </a> {% elif message.sender != user %} <a href="{% url 'user_msgreply' message.id %}" class="btn btn-sm btn-secondary"> <i class="fas fa-reply"></i> 回覆 </a> {% endif %} </div> </div> {% endblock %}

這個範本被檢視私人訊息以及檢視課程公告共用,有做一些不同的呈現方式,只有私訊可以回覆。

建立課程公告/傳送私人訊息表單

建立課程公告或傳送與回覆私人訊息時的表單範本也可以共用,請新增範本檔 templates/user/message_form.html

{% extends "base.html" %} {% block content %} <div class="card"> <div class="card-header"> {% if course %} 課程: {{ course.name }} {% else %} 收件人: {{ recipient.username }}({{ recipient.first_name }}) {% endif %} </div> <div class="card-body"> <form id="msg-form" action="" method="post"> {% csrf_token %} <input type="hidden" name="success_url" value="{{ success_url }}"> <table class="table table-sm"> {{ form.as_table }} </table> <input type="submit" class="btn btn-sm btn-primary" value="送出"> </form> </div> </div> {% endblock %} {% block custom_scripts %} <script> add_css_class('form table input, form table textarea', 'form-control'); add_css_class('.errorlist', 'alert', 'alert-danger'); add_css_class('.helptext ul', 'alert', 'alert-light'); </script> {% endblock custom_scripts %}

第 6 - 10 行的條件結構來分別處理公告或私訊。

檢視課程

開啟檢視課程範本 templates/course/course_detail.html,依下列方式修改:

{% block course_detail_body %} <div id="student_op" class="btn-group"> {% if not course|has_member:user and not user.is_superuser %} <a href="{% url 'course_enroll' course.id %}" class="{{ b1 }}"> <i class="fas fa-id-badge"></i> 選修 </a> {% else %} <a href="{% url 'course_users' course.id %}" class="{{ b1 }}"> <i class="fas fa-users"></i> 修課名單 </a> <a href="{% url 'course_msglist' course.id %}" class="{{ b1 }}"> <i class="fas fa-list"></i> 公告列表 </a> {% if course|has_student:user %} <a href="{% url 'user_msgsend' course.teacher_id %}" class="{{ b1 }}"> <i class="fas fa-paper-plane"></i> 提問 </a> <a href="{% url 'course_seat' course.id %}" class="{{ b1 }}"> <i class="fas fa-chair"></i> 更改座號 </a> {% endif %} {% endif %} </div> {% if course.teacher == user %} <div id="teacher_op" class="btn-group"> <a href="{% url 'course_msgbroadcast' course.id %}" class="{{ btn2 }}"> <i class="fas fa-bullhorn"></i> 發布公告 </a> </div> {% endif %} {% endblock %}

修課名單

修改修課名單範本 templates/course/user_list.html,插入第 13, 24 - 30 行,在非目前使用者的修課學生後面加上「傳訊」按鈕:

<table class="table table-sm table-hover"> <thead> <tr> <th>學校</th> <th>帳號</th> <th>座號</th> <th>姓名</th> <th>最近登入時間</th> <th></th> </tr> </thead> <tbody> {% for enroll in enroll_list %} <tr> <td>{{ enroll.stu.last_name }}</td> <td>{{ enroll.stu.username }}</td> <td>{{ enroll.seat }}</td> <td>{{ enroll.stu.first_name }}</td> <td>{{ enroll.stu.last_login }}</td> <td> {% if enroll.stu_id != user.id %} <a href="{% url 'user_msgsend' enroll.stu_id %}" class="btn btn-sm btn-secondary"> <i class="fas fa-paper-plane"></i> 傳訊 </a> {% endif %} </td> </tr> {% endfor %} </tbody> </table>