Bài 30: Kiến trúc Model View-SQL Databases–PyQt6–Part 5

Bài này Tui sẽ trình bày về ứng dụng kiến trúc Model View trong tương tác và hiển thị cơ sở dữ liệu dạng SQL, cụ thể là SQLite Database, cung cấp tính năng lọc dữ liệu theo dòng, lọc dữ liệu theo cột, cũng như cập nhật dữ liệu ngay trong QTableView khi có sự thay đổi, bạn có thể áp dụng để làm các loại cơ sở dữ liệu khác như PostGreSQL, MySQL…

Khi sử dụng PyQt để kết nối tới các loại cơ sở dữ liệu khác nhau ta cần kiểm tra xem PyQt đã có các loại Drivers nào rồi, nếu chưa có ta cần cài đặt.

Để kiểm tra các loại Drivers này ta dùng lệnh dưới đây:

from PyQt6.QtSql import QSqlDatabase

drivers=QSqlDatabase.drivers()

print(drivers)

Giả sử khi chạy lệnh trên, bạn có mảng các Driver sau trong máy tính:

['QSQLITE','QMYSQL', 'QMYSQL3', 'QODBC', 'QODBC3', 'QPSQL', 'QPSQL7']

Máy tính có Driver nào thì ta mới sử dụng được Driver đó. Như vậy tùy vào nhu cầu sử dụng mà ta cần cài Driver phù hợp nếu PyQt chưa support trong máy của bạn.

Để kết nối tới các Remote Server ví dụ như MYSQL, Microsoft SQL Server… thì bạn dùng tập các mã lệnh đưới đây:

db = QSqlDatabase('<driver>')
db.setHostName('<localhost>')
db.setPort('<port number>')
db.setDatabaseName('<databasename>')
db.setUserName('<username>')
db.setPassword('<password>')
db.open()

Trong bài học này chúng ta kết nối với Local server, cụ thể là SQLite Database. Ví dụ ta muốn kết nối tới cơ sở dữ liệu “Chinook_Sqlite.sqlite” đặt trong cùng thư mục dự án, thì chúng ta sẽ sử dụng các lệnh sau:

baseDir=os.path.dirname(__file__)
databasePath=os.path.join(baseDir,"Chinook_Sqlite.sqlite")
self.db=QSqlDatabase("QSQLITE")
self.db.setDatabaseName(databasePath)
self.db.open()

Sau khi kết nối tới SQLite Database thành công, ta có thể tạo đối tượng QSqlTableModel để truy suất tới bảng dữ liệu trong SQLite Database, ví dụ bảng “Employee“:

self.model = QSqlTableModel(db=self.db)
self.model.setTable("Employee")
self.model.select()
self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnFieldChange)

Hàm setEditStrategy() nhận vào enum để xác định cách thức cập nhật dữ liệu trên model:

EnumÝ nghĩa chức năng
QSqlTableModel.EditStrategy.OnFieldChangeMô hình sẽ tự động cập nhật dữ liệu khi người dùng di chuyển ra khỏi ô nhập liệu
QSqlTableModel.EditStrategy.OnRowChangeMô hình sẽ tự động cập nhật dữ liệu khi người dùng di chuyển ra khỏi dòng nhập liệu
QSqlTableModel.EditStrategy.OnManualSubmitChương trình không tự động cập nhật dữ liệu ngay, mà nó lưu những dòng dữ liệu được chỉnh sửa vào caches. Và khi muốn cập nhật thì ta gọi hàm submitAll() hoặc nếu hủy sự thay đổi thì ta gọi hàm revertAll()

Để hiển thị dữ liệu lên QTableView ta gọi lệnh sau:

self.tableView.setModel(self.model)

Để filter dữ liệu theo các thuộc tính ta viết lệnh:

def processFilterName(self,s):
    filter_str = 'LastName LIKE "%{}%" or FirstName LIKE "%{}%"'.format(s,s)
    self.model.setFilter(filter_str)

Ví dụ mã lệnh ở trên là filter dữ liệu theo LastName và FirstName. Chẳng hạn như lọc ra các Employee mà FirstName và LastName có chứa từ “an”.

Dưới đây là bảng ý nghĩa cú pháp của Filter:

Cú pháp(Pattern)Ý nghĩa chức năng
columnName=“{}”Lọc chính xác dữ liệu mà thuộc tính
columnName LIKE “{}%”Lọc các dòng dữ liệu mà nó chứa chuỗi so khớp đằng trước nó
columnName LIKE %{}”Lọc các dòng dữ liệu mà nó chứa chuỗi so khớp đằng sau nó
columnName LIKE %{}%”Lọc ra các dòng dữ liệu mà nó có chứa chuỗi so khớp

Trong mỗi pattern ở trên, {} là chuỗi tìm kiếm mà ta phải viết:

“{}”.format(search_str)

Ngoài ra chúng ta có thể dùng QSqlQuery để lọc dữ liệu theo cột:

def processFilterColumns(self):
    query = QSqlQuery("SELECT EmployeeId, FirstName, LastName FROM Employee ", db=self.db)
    self.model.setQuery(query)

Mã lệnh ở trên sẽ giúp truy vấn dữ liệu nhưng chỉ lọc ra các cột EmployeeId, FirstName, LastName

Bây giờ ta đi vào chi tiết từng bước để ứng dụng kiến trúc Model View, sử dụng QSqlTableModel để truy vấn/mapping bảng dữ liệu và hiển thị Lên QTableView.

Bước 1: Tạo dự án “LearnModelViewSQLitePart5” có cấu trúc như dưới đây:

  • “Chinook_Sqlite.sqlite” là cơ sở dữ liệu SQLite mẫu để giới thiệu ở bài học trước
  • “MainWindow.ui” là file thiết kế giao diện bằng Qt Designer
  • “MainWindow.py” là file Generate Python code của MainWindow.ui
  • “MainWindowEx.py” là mã lệnh kế thừa từ Generate Python code để xử lý sự kiện người dùng, nạp giao diện, gán model
  • “MyApp.py” là file mã lệnh để thực thi chương trình

Bước 2: Thiết kế giao diện “MainWindow.ui” như hình dưới đây:

Ta kéo thả và đặt tên cho các Widget như trên

Bước 3: Generate Python code cho MainWindow.ui, đặt tên MainWindow.py:

# Form implementation generated from reading ui file 'MainWindow.ui'
#
# Created by: PyQt6 UI code generator 6.4.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(463, 327)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
        self.verticalLayout.setObjectName("verticalLayout")
        self.label = QtWidgets.QLabel(parent=self.centralwidget)
        self.label.setObjectName("label")
        self.verticalLayout.addWidget(self.label)
        self.lineEditFilter = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditFilter.setObjectName("lineEditFilter")
        self.verticalLayout.addWidget(self.lineEditFilter)
        self.tableView = QtWidgets.QTableView(parent=self.centralwidget)
        self.tableView.setObjectName("tableView")
        self.verticalLayout.addWidget(self.tableView)
        self.pushButtonFilter = QtWidgets.QPushButton(parent=self.centralwidget)
        self.pushButtonFilter.setObjectName("pushButtonFilter")
        self.verticalLayout.addWidget(self.pushButtonFilter, 0, QtCore.Qt.AlignmentFlag.AlignLeft)
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 463, 26))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Trần Duy Thanh - SQLite"))
        self.label.setText(_translate("MainWindow", "Filter name:"))
        self.pushButtonFilter.setText(_translate("MainWindow", "Filter Columns"))

Bước 5: Tạo “MainWindowEx.py“, kế thừa từ Generate Python code để nạp giao diện, xử lý nghiệp vụ, gán mô hình:

import os.path

from PyQt6.QtSql import QSqlDatabase, QSqlTableModel, QSqlQuery

from MainWindow import Ui_MainWindow


class MainWindowEx(Ui_MainWindow):
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow
        self.loadDatabase()
        self.lineEditFilter.textChanged.connect(self.processFilterName)
        self.pushButtonFilter.clicked.connect(self.processFilterColumns)

Ta overrice setupUi() để nạp giao diện, gọi hiển thị dữ liệu cũng như xử lý các signal cho widget.

Tiếp theo ta viết hàm loadDatabase(), hàm này sẽ đọc SQLite Database và truy vấn bảng “Empoyee” rồi hiển thị lên QTableView, sử dụng kiến trúc Model View:

def loadDatabase(self):
    baseDir=os.path.dirname(__file__)
    databasePath=os.path.join(baseDir,"Chinook_Sqlite.sqlite")
    self.db=QSqlDatabase("QSQLITE")
    self.db.setDatabaseName(databasePath)
    self.db.open()
    self.model = QSqlTableModel(db=self.db)
    self.model.setTable("Employee")
    self.model.select()
    self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnFieldChange)
    self.tableView.setModel(self.model)

Tiếp đến là ta xử lý lọc dữ liệu theo dòng khi người dùng nhập liệu vào QLineEdit:

def processFilterName(self,s):
    filter_str = 'LastName LIKE "%{}%" or FirstName LIKE "%{}%"'.format(s,s)
    self.model.setFilter(filter_str)

Tiếp sau đó ta viết hàm filter dữ liệu theo cột bằng QSqlQuery, đây là đối tượng rất tiện lợi cho ta tùy biến truy vấn dữ liệu:

def processFilterColumns(self):
    query = QSqlQuery("SELECT EmployeeId, FirstName, LastName FROM Employee ", db=self.db)
    self.model.setQuery(query)

Dưới đây là mã lệnh đầy đủ của MainWindowEx.py:

import os.path

from PyQt6.QtSql import QSqlDatabase, QSqlTableModel, QSqlQuery

from MainWindow import Ui_MainWindow


class MainWindowEx(Ui_MainWindow):
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow
        self.loadDatabase()
        self.lineEditFilter.textChanged.connect(self.processFilterName)
        self.pushButtonFilter.clicked.connect(self.processFilterColumns)
    def loadDatabase(self):
        baseDir=os.path.dirname(__file__)
        databasePath=os.path.join(baseDir,"Chinook_Sqlite.sqlite")
        self.db=QSqlDatabase("QSQLITE")
        self.db.setDatabaseName(databasePath)
        self.db.open()
        self.model = QSqlTableModel(db=self.db)
        self.model.setTable("Employee")
        self.model.select()
        self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnFieldChange)
        self.tableView.setModel(self.model)
    def processFilterName(self,s):
        filter_str = 'LastName LIKE "%{}%" or FirstName LIKE "%{}%"'.format(s,s)
        self.model.setFilter(filter_str)
    def processFilterColumns(self):
        query = QSqlQuery("SELECT EmployeeId, FirstName, LastName FROM Employee ", db=self.db)
        self.model.setQuery(query)
    def show(self):
        self.MainWindow.show()

Bước 6: Cuối cùng ta viết mã lệnh “MyApp.py”

from PyQt6.QtWidgets import QApplication, QMainWindow

from MainWindowEx import MainWindowEx

app=QApplication([])
myWindow=MainWindowEx()
myWindow.setupUi(QMainWindow())
myWindow.show()
app.exec()

Chạy MyApp.Py ta có kết quả như mong muốn:

Như vậy là Tui đã hướng dẫn xong cách ứng dụng kiến trúc Model View trong tương tác và hiển thị cơ sở dữ liệu dạng SQL, cụ thể là SQLite Database, cung cấp tính năng lọc dữ liệu theo dòng, lọc dữ liệu theo cột, cũng như cập nhật dữ liệu ngay trong QTableView khi có sự thay đổi, bạn có thể áp dụng để làm các loại cơ sở dữ liệu khác.

Các bạn tải source code đầy đủ ở đây:

https://www.mediafire.com/file/d568w6uiz3qc8jh/LearnModelViewSQLitePart5.rar/file

Bài học sau Tui hướng dẫn các bạn cách trực quan hóa dữ liệu bằng các Chart, đây là một trong các thư viện phổ biến và quan trọng trong trình bày trực quan dữ liệu để đưa ra các đồ thị, biểu đồ. Các bạn chú ý theo dõi

Chúc các bạn thành công

Bài 29: Kiến trúc Model View-PyQt6–Part 4

Bài này Tui nâng cấp bài 28, tiếp tục ứng dụng kiến trúc Model View và bổ sung các chức năng cho QTableView:

  • Nạp ma trận “data” lên QTableView cùng với các cột được lưu trong mảng “columns“? Đồng thời nếu giá <100 thì tô chữ đỏ, nếu tên rỗng thì tô nền vàng:
  • Thêm, Sửa, Xóa dữ liệu ngay trên giao diện QTableView thông qua việc cập nhật mã lệnh mới cho TableModel.py.
  • Xử lý signal item selection change
  • Cách khai báo và sử dụng Enum trong Python
  • Xử lý Context Menu cho Widget để Chèn Đầu, Chèn Cuối, Chèn Trước, Chèn Sau dữ liệu vào trong QTableView, cũng như thao tác xóa dữ liệu ra khỏi QTableView.
  • Dùng partial function để triệu gọi các signals/slots có parameter
  • Import và Export dữ liệu trong model ra JSON ARRAY

Bài này nhiều chức năng, khá khó và tuy nhiên nó có tính ứng dụng cao trong thực tế. Các bạn có gắng thực hành theo từng bước để sau náy có thể áp dụng nó và các tình huống khác nhau.

Bước 1: Tạo dự án “LearnModelViewPart4” có cấu trúc như hình dưới đây:

  • Thư mục “images” chứa các hình ảnh và biểu tượng cho các QAction và Window Icon
  • Mã lệnh “InsertBehavior.py” là enum để định nghĩa các kiểu insert trước, sau, đầu, cuối
  • Mã lệnh “TableModel.py” là lớp Model View, lớp trung tâm của dự án
  • MainWindow.ui” là giao diện thiết kế phần mềm
  • Mã lệnh “MainWindow.py” là Generate Python code từ giao diện
  • MainWindowEx.py” là mã lệnh kế thừa từ lớp Generate Python code để xử lý nạp giao diện cũng như xử lý sự kiện người dùng, gán Model.
  • MyApp.py” là mã lệnh thực thi chương trình

Bước 2: Tạo mã lệnh enum “InsertBehavior.py”

from enum import Enum

class InsertBehavior(Enum):
        INSERT_FIRST = 0
        INSERT_LAST = 1
        INSERT_ABOVE = 2
        INSERT_BELOW=3

Trong InsertBehavior Tui định nghĩa 4 enum, và nó sẽ được dùng cho xử lý Context Menu trên QTableView.

Context Menu trên QTableView (nhấn chuột phải vào QTableView), chương trình sẽ hiển thị lên Context Menu ở trên. Có chức năng tương ứng với enum:

  • Insert First (INSERT_FIRST = 0), chương trình sẽ thêm một dòng trống ở đầu QTableView
  • Insert Last (INSERT_LAST = 1), chương trình sẽ thêm một dòng trống ở cuối QTableView
  • Insert Above (INSERT_ABOVE = 2), chương trình sẽ thêm một dòng trống ở trước dòng đang lựa chọn
  • Insert Below (INSERT_BELOW=3), chương trình sẽ thêm một dòng trống ở sau dòng đang lựa chọn

Ngoài ra Tui cũng bổ sung thêm 1 Context Menu khi người dùng nhấn chuột phải vào QTableView nhưng không nhấn vào dòng nào:

Trong trường hợp này thì chỉ có duy nhất một QAction là “Insert New Record” và nó dùng chung enum (INSERT_FIRST = 0)

Bước 3: Viết mã lệnh “TableModel.py“, đây là lớp mã lệnh trung tâm của dự án liên quan tới kiến trúc Model View.

Lớp TableModel kế thừa từ QAbstractTableModel, Tui định nghĩa constructor có 2 đối số nhận vào đó là ma trận dữ liệu (data) và mảng tên cột (columns)

from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex

class TableModel(QAbstractTableModel):
    def __init__(self, data,columns):
        super().__init__()
        self.data = data
        self.columns=columns

Tiếp theo ta override hàm data() để hiển thị dữ liệu và định dạng dữ liệu như mong muốn:

def data(self, index, role):
    value = self.data[index.row()][index.column()]
    if role == Qt.ItemDataRole.DisplayRole:
        return value
    if role==Qt.ItemDataRole.EditRole:
        return value
    if role == Qt.ItemDataRole.BackgroundRole:
        if index.column() == 1 and value == "":
            return QtGui.QColor(Qt.GlobalColor.yellow)
    if role == Qt.ItemDataRole.ForegroundRole:
        if index.column() == 2 and  value!="" and float(value) < 100:
            return QtGui.QColor(Qt.GlobalColor.red)

Thông qua hàm data() này, chương trình sẽ tô chữ đỏ với dòng dữ liệu có giá <100, tô nền vàng với dòng dữ liệu có tên rỗng.

Tiếp theo ta cần override hàm rowCount() và columnCount() để Model nhận dạng được số dòng và số cột trong giao diện:

def rowCount(self, index):
    return len(self.data)

def columnCount(self, index):
    return len(self.columns)

Để tạo tiêu đề cột và tiêu đề dòng, ta cần override phương thức headerData():

def headerData(self, section, orientation, role):
    if role == Qt.ItemDataRole.DisplayRole:
        if orientation == Qt.Orientation.Horizontal:
            return str(self.columns[section])
        if orientation==Qt.Orientation.Vertical:
            return  str(section+1)
  • Qt.Orientation.Horizontal là enum dùng để định nghĩa tiêu đề cột
  • Qt.Orientation.Vertical là enum dùng để định nghĩa tiêu đề dòng

Tiếp theo ta override 2 hàm flags() và setData():

def flags(self, index):
    if not index.isValid():
        return Qt.ItemFlag.ItemIsEnabled

    return super().flags(index) | Qt.ItemFlag.ItemIsEditable  # add editable flag.

def setData(self, index, value, role):
    if role == Qt.ItemDataRole.EditRole:
        # Set the value into the frame.
        self.data[index.row()][index.column()] = value
        return True
    return False
  • Hàm flags() dùng để customize tính năng cho người dùng chỉnh sửa dữ liệu trực tiếp trên từng ô dữ liệu
  • Hàm setData() dùng để bổ sung cho tính năng flags() đó là khi nhấn vào ô nào thì hiển thị chức năng cập nhật (Ô textbox) đồng thời hiển thị lại dữ liệu hiện hành). Cũng như khi người dùng đổi dữ liệu mới thì dữ liệu này sẽ được cập nhật vào ma trận: self.data[index.row()][index.column()] = value

Tiếp theo ta override hàm insertRows() để hỗ trợ việc chèn mới dòng dữ liệu cho Model:

def insertRows(self, row, rows=1, index=QModelIndex()):
    print("Inserting at row: %s" % row)
    self.beginInsertRows(QModelIndex(), row, row + rows - 1)
    self.data.insert(row,["","",""])
    self.endInsertRows()
    return True

Mã lệnh bên trong hàm insertRows() chương trình sẽ tạo thêm 1 dòng dữ liệu trên QTableView đồng thời cũng chèn dữ liệu rỗng cho ma trận trong biến data.

Hàm insertRows() sẽ dùng cho các chức năng: Chèn đầu, chèn cuối, chèn trước và chèn sau.

Để thực hiện chức năng xóa dữ liệu ta override hàm removeRows():

def removeRows(self, row, rows=1, index=QModelIndex()):
    print("Removing at row: %s" % row)
    self.beginRemoveRows(QModelIndex(), row, row + rows - 1)
    #self.data = self.data[:row] + self.data[row + rows:]
    del self.data[row]
    self.endRemoveRows()
    return True

Mã lệnh trong removeRows() sẽ xóa dòng ở vị trí row, đồng thời ta cũng gọi lệnh để xóa dữ liệu trong ma trận ở dòng row: del self.data[row]

Dưới đây là mã lệnh đầy đủ của “TableModel.py“:

from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex

class TableModel(QAbstractTableModel):
    def __init__(self, data,columns):
        super().__init__()
        self.data = data
        self.columns=columns

    def data(self, index, role):
        value = self.data[index.row()][index.column()]
        if role == Qt.ItemDataRole.DisplayRole:
            return value
        if role==Qt.ItemDataRole.EditRole:
            return value
        if role == Qt.ItemDataRole.BackgroundRole:
            if index.column() == 1 and value == "":
                return QtGui.QColor(Qt.GlobalColor.yellow)
        if role == Qt.ItemDataRole.ForegroundRole:
            if index.column() == 2 and  value!="" and float(value) < 100:
                return QtGui.QColor(Qt.GlobalColor.red)

    def rowCount(self, index):
        return len(self.data)

    def columnCount(self, index):
        return len(self.columns)

    def headerData(self, section, orientation, role):
        if role == Qt.ItemDataRole.DisplayRole:
            if orientation == Qt.Orientation.Horizontal:
                return str(self.columns[section])
            if orientation==Qt.Orientation.Vertical:
                return  str(section+1)

    def flags(self, index):
        if not index.isValid():
            return Qt.ItemFlag.ItemIsEnabled

        return super().flags(index) | Qt.ItemFlag.ItemIsEditable  # add editable flag.

    def setData(self, index, value, role):
        if role == Qt.ItemDataRole.EditRole:
            # Set the value into the frame.
            self.data[index.row()][index.column()] = value
            return True
        return False

    def insertRows(self, row, rows=1, index=QModelIndex()):
        print("Inserting at row: %s" % row)
        self.beginInsertRows(QModelIndex(), row, row + rows - 1)
        self.data.insert(row,["","",""])
        self.endInsertRows()
        return True

    def removeRows(self, row, rows=1, index=QModelIndex()):
        print("Removing at row: %s" % row)
        self.beginRemoveRows(QModelIndex(), row, row + rows - 1)
        #self.data = self.data[:row] + self.data[row + rows:]
        del self.data[row]
        self.endRemoveRows()
        return True

Bước 4: Thiết kế giao diện “MainWindow.ui” bằng Qt Designer

Các bạn kéo thả các widget QLineEdit, và nhập menu cũng như tên các widget như trên.

Bước 5: Generate Python Code “MainWindow.py” cho “MainWindow.ui”

# Form implementation generated from reading ui file 'MainWindow.ui'
#
# Created by: PyQt6 UI code generator 6.5.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(330, 329)
        icon = QtGui.QIcon()
        icon.addPixmap(QtGui.QPixmap("images/ic_logo.jpg"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        MainWindow.setWindowIcon(icon)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.tableViewProduct = QtWidgets.QTableView(parent=self.centralwidget)
        self.tableViewProduct.setGeometry(QtCore.QRect(20, 10, 291, 181))
        self.tableViewProduct.setObjectName("tableViewProduct")
        self.label = QtWidgets.QLabel(parent=self.centralwidget)
        self.label.setGeometry(QtCore.QRect(20, 200, 71, 16))
        self.label.setObjectName("label")
        self.lineEditProductId = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditProductId.setGeometry(QtCore.QRect(100, 200, 191, 20))
        self.lineEditProductId.setObjectName("lineEditProductId")
        self.lineEditProductName = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditProductName.setGeometry(QtCore.QRect(100, 230, 191, 20))
        self.lineEditProductName.setObjectName("lineEditProductName")
        self.label_2 = QtWidgets.QLabel(parent=self.centralwidget)
        self.label_2.setGeometry(QtCore.QRect(20, 230, 71, 16))
        self.label_2.setObjectName("label_2")
        self.label_3 = QtWidgets.QLabel(parent=self.centralwidget)
        self.label_3.setGeometry(QtCore.QRect(20, 260, 71, 16))
        self.label_3.setObjectName("label_3")
        self.lineEditUnitPrice = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditUnitPrice.setGeometry(QtCore.QRect(100, 260, 191, 20))
        self.lineEditUnitPrice.setObjectName("lineEditUnitPrice")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 330, 22))
        self.menubar.setObjectName("menubar")
        self.menuSystem = QtWidgets.QMenu(parent=self.menubar)
        self.menuSystem.setObjectName("menuSystem")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)
        self.actionExport_to_JSon = QtGui.QAction(parent=MainWindow)
        icon1 = QtGui.QIcon()
        icon1.addPixmap(QtGui.QPixmap("images/ic_export.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionExport_to_JSon.setIcon(icon1)
        self.actionExport_to_JSon.setObjectName("actionExport_to_JSon")
        self.actionImport_from_JSon = QtGui.QAction(parent=MainWindow)
        icon2 = QtGui.QIcon()
        icon2.addPixmap(QtGui.QPixmap("images/ic_import.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionImport_from_JSon.setIcon(icon2)
        self.actionImport_from_JSon.setObjectName("actionImport_from_JSon")
        self.actionExit = QtGui.QAction(parent=MainWindow)
        icon3 = QtGui.QIcon()
        icon3.addPixmap(QtGui.QPixmap("images/ic_exit.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionExit.setIcon(icon3)
        self.actionExit.setObjectName("actionExit")
        self.menuSystem.addAction(self.actionExport_to_JSon)
        self.menuSystem.addSeparator()
        self.menuSystem.addAction(self.actionImport_from_JSon)
        self.menuSystem.addSeparator()
        self.menuSystem.addAction(self.actionExit)
        self.menubar.addAction(self.menuSystem.menuAction())

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Trần Duy Thanh - Model View"))
        self.label.setText(_translate("MainWindow", "Product Id:"))
        self.label_2.setText(_translate("MainWindow", "Product Name:"))
        self.label_3.setText(_translate("MainWindow", "Unit Price:"))
        self.menuSystem.setTitle(_translate("MainWindow", "System"))
        self.actionExport_to_JSon.setText(_translate("MainWindow", "Export to JSon"))
        self.actionImport_from_JSon.setText(_translate("MainWindow", "Import from JSon"))
        self.actionExit.setText(_translate("MainWindow", "Exit"))

Bước 6: Viết mã lệnh “MainWindowEx.py” để xử lý sự kiện, nạp giao diện, gán model. Lớp này kế thừa từ Generate Python code

Ta định nghĩa Constructor cho MainWindowEx.py để khởi tạo ma trận dữ liệu mẫu và các tiêu đề cột:

import json
from functools import partial

from PyQt6.QtCore import QModelIndex, Qt
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QMenu, QFileDialog
from InsertBehavior import InsertBehavior
from MainWindow import Ui_MainWindow
from TableModel import TableModel

class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        self.data = [["p1", "Coca", 100],
                ["p2", "Pepsi", 50],
                ["p3", "Sting", 300],
                ["p4", "Aqua", 70],
                ["p5", "Redbull", 200],
                ["p6", "", 120]]
        #self.data = []
        self.columns = ["ID", "Name", "Price"]

Nếu bạn muốn khởi tạo ma trận rỗng thì dùng lệnh: self.data=[]

Tiếp theo ta Override hàm setupUi() để nạp giao diện cũng như gán đối tượng TableModel cho QTableView:

def setupUi(self, MainWindow):
    super().setupUi(MainWindow)
    self.MainWindow=MainWindow

    self.model = TableModel(self.data, self.columns)
    self.tableViewProduct.setModel(self.model)

    self.tableViewProduct.selectionModel().selectionChanged.connect(self.processItemSelection)

    self.tableViewProduct.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
    self.tableViewProduct.customContextMenuRequested.connect(self.onCustomContextMenuRequested)

    self.tableViewProduct.verticalHeader().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
    self.tableViewProduct.verticalHeader().customContextMenuRequested.connect(self.onCustomContextMenuRequested)

    self.actionExport_to_JSon.triggered.connect(self.processExportJson)
    self.actionImport_from_JSon.triggered.connect(self.processImportJson)

Đồng thời ta cũng khai báo các signal để xử lý sự kiện selectionChange và đăng ký các Context Menu trên Cho QTableView (khi nhấn chuột phải vào các ô bên trong QTableView hoặc nhấn chuột phải vào tiêu đề dòng).

Hàm processItemSelection() sẽ xử lý lấy dữ liệu đang chọn tại dòng hiện hành và hiển thị thông tin chi tiết lên QLineEdit:

def processItemSelection(self):
    index=self.tableViewProduct.currentIndex()
    if index.row()>-1:
        item=self.data[index.row()]
        self.lineEditProductId.setText(item[0])
        self.lineEditProductName.setText(item[1])
        self.lineEditUnitPrice.setText(str(item[2]))

Ta viết mã lệnh cho hàm onCustomContextMenuRequested() để khởi tạo QMenu context menu:

def onCustomContextMenuRequested(self, pos):
    index = self.tableViewProduct.indexAt(pos)
    menu = QMenu()
    if index.isValid():
        insertFirst = menu.addAction("Insert &First")
        insertFirst.setIcon(QIcon("images/ic_first.png"))
        insertLast = menu.addAction("Insert &Last")
        insertLast.setIcon(QIcon("images/ic_last.png"))
        insertAbove = menu.addAction("Insert &Above")
        insertAbove.setIcon(QIcon("images/ic_above.png"))
        insertBelow = menu.addAction("Insert &Below")
        insertBelow.setIcon(QIcon("images/ic_below.png"))
        removeSelected = menu.addAction("Remove selected row")
        removeSelected.setIcon(QIcon("images/ic_delete.png"))

        menu.addAction(insertFirst)
        menu.addAction(insertLast)
        menu.addAction(insertAbove)
        menu.addAction(insertBelow)
        menu.addSeparator()
        menu.addAction(removeSelected)

        insertFirst.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_FIRST))
        insertLast.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_LAST))
        insertAbove.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_ABOVE))
        insertBelow.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_BELOW))
        removeSelected.triggered.connect(self.processDelete)
        menu.exec(self.tableViewProduct.viewport().mapToGlobal(pos))
        menu.close()
    else:
        insertNew = menu.addAction("Insert New Record")
        insertNew.setIcon(QIcon("images/ic_insertnew.png"))
        menu.addAction(insertNew)
        insertNew.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_FIRST))
        menu.exec(self.tableViewProduct.viewport().mapToGlobal(pos))
        menu.close()

Lệnh trên sẽ giúp tạo 2 QMenu ở 2 case khác nhau như đã nói ở trên.

Hàm:

index = self.tableViewProduct.indexAt(pos)

Sẽ cho ta biết dòng nào đang được chọn

Nếu:

index.isValid()

là hợp lệ thì tạo Context menu:

Nếu:

index.isValid()

là không hợp lệ, thì tạo Context menu:

Bạn lưu ý quan trọng là Tui có viết kỹ thuật partial để các signal triệu gọi các slot mà có truyền parameter:

insertFirst.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_FIRST))
insertLast.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_LAST))
insertAbove.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_ABOVE))
insertBelow.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_BELOW))

Ta bổ sung hàm processInsert() để xử lý: Chèn đầu, chèn cuối, chèn trước, chèn sau:

def processInsert(self,behavior=InsertBehavior.INSERT_FIRST):
    indexes = self.tableViewProduct.selectionModel().selectedIndexes()
    if behavior==InsertBehavior.INSERT_FIRST:
        row=0
    elif behavior==InsertBehavior.INSERT_LAST:
        row = self.tableViewProduct.model().rowCount(QModelIndex())+1
    else:
        if indexes:
            index=indexes[0]
            row=index.row()
            if behavior==InsertBehavior.INSERT_ABOVE:
                row=max(row,0)
            else:
                size = self.tableViewProduct.model().rowCount(QModelIndex())
                row = min(row + 1, size)
    self.tableViewProduct.model().insertRows(row, 1, QModelIndex())

Dựa vào các enum trong InsertBehavior mà ta gọi các lệnh Chèn cho phù hợp với từng index.

Tương tự như vậy, ta viết mà lệnh cho hàm processDelete():

def processDelete(self):
    indexes = self.tableViewProduct.selectionModel().selectedIndexes()
    if indexes:
            index=indexes[0]
            row = index.row()
            self.tableViewProduct.model().removeRows(row, 1, QModelIndex())

Hàm processDelete() sẽ xóa dòng đang chọn.

Cuối cùng là tới nhóm hàm dành cho các Menu.

Hàm processExportJson() để xuất dữ liệu mà người dùng trên QTableView ra JSon Array:

def processExportJson(self):
    # setup for QFileDialog
    filters = "Dataset (*.json);;All files(*)"
    filename, selected_filter = QFileDialog.getSaveFileName(
        self.MainWindow,
        filter=filters,
    )
    #self.fileFactory.writeData(filename,self.model.data)
    with open(filename, 'w') as jf:
        jsonString = json.dumps(self.model.data)
        jsonFile = open(filename, "w")
        jsonFile.write(jsonString)
        jsonFile.close()

Hàm trên cũng dùng QFileDialog dạng Save File Dialog để cho người dùng chọn nơi lưu trữ bất kỳ.

Hàm processImportJson() để nạp dữ liệu từ file JSon đã lưu ở ổ cứng, khởi tạo dữ liệu cho model và hiển thị lại giao diện:

def processImportJson(self):
    # setup for QFileDialog
    filters = "Dataset (*.json);;All files(*)"
    filename, selected_filter = QFileDialog.getOpenFileName(
        self.MainWindow,
        filter=filters,
    )
    with open(filename, 'r') as jf:
        lines = jf.readline()
        print(lines)
        self.data= json.loads(lines)
        self.model.data= self.data
    self.model.layoutChanged.emit()

Hàm sẽ khởi tạo lại ma trậ ncho self.data và truyền lệnh cho QTableView cập nhật lại giao diện thông qua lệnh:

self.model.layoutChanged.emit()

Dưới đã là mã lệnh chi tiết của MainWindowEx.py:

import json
from functools import partial

from PyQt6.QtCore import QModelIndex, Qt
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QMenu, QFileDialog
from InsertBehavior import InsertBehavior
from MainWindow import Ui_MainWindow
from TableModel import TableModel

class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        self.data = [["p1", "Coca", 100],
                ["p2", "Pepsi", 50],
                ["p3", "Sting", 300],
                ["p4", "Aqua", 70],
                ["p5", "Redbull", 200],
                ["p6", "", 120]]
        #self.data = []
        self.columns = ["ID", "Name", "Price"]
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow

        self.model = TableModel(self.data, self.columns)
        self.tableViewProduct.setModel(self.model)

        self.tableViewProduct.selectionModel().selectionChanged.connect(self.processItemSelection)

        self.tableViewProduct.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tableViewProduct.customContextMenuRequested.connect(self.onCustomContextMenuRequested)

        self.tableViewProduct.verticalHeader().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tableViewProduct.verticalHeader().customContextMenuRequested.connect(self.onCustomContextMenuRequested)

        self.actionExport_to_JSon.triggered.connect(self.processExportJson)
        self.actionImport_from_JSon.triggered.connect(self.processImportJson)
    def processItemSelection(self):
        index=self.tableViewProduct.currentIndex()
        if index.row()>-1:
            item=self.data[index.row()]
            self.lineEditProductId.setText(item[0])
            self.lineEditProductName.setText(item[1])
            self.lineEditUnitPrice.setText(str(item[2]))

    def onCustomContextMenuRequested(self, pos):
        index = self.tableViewProduct.indexAt(pos)
        menu = QMenu()
        if index.isValid():
            insertFirst = menu.addAction("Insert &First")
            insertFirst.setIcon(QIcon("images/ic_first.png"))
            insertLast = menu.addAction("Insert &Last")
            insertLast.setIcon(QIcon("images/ic_last.png"))
            insertAbove = menu.addAction("Insert &Above")
            insertAbove.setIcon(QIcon("images/ic_above.png"))
            insertBelow = menu.addAction("Insert &Below")
            insertBelow.setIcon(QIcon("images/ic_below.png"))
            removeSelected = menu.addAction("Remove selected row")
            removeSelected.setIcon(QIcon("images/ic_delete.png"))

            menu.addAction(insertFirst)
            menu.addAction(insertLast)
            menu.addAction(insertAbove)
            menu.addAction(insertBelow)
            menu.addSeparator()
            menu.addAction(removeSelected)

            insertFirst.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_FIRST))
            insertLast.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_LAST))
            insertAbove.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_ABOVE))
            insertBelow.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_BELOW))
            removeSelected.triggered.connect(self.processDelete)
            menu.exec(self.tableViewProduct.viewport().mapToGlobal(pos))
            menu.close()
        else:
            insertNew = menu.addAction("Insert New Record")
            insertNew.setIcon(QIcon("images/ic_insertnew.png"))
            menu.addAction(insertNew)
            insertNew.triggered.connect(partial(self.processInsert, InsertBehavior.INSERT_FIRST))
            menu.exec(self.tableViewProduct.viewport().mapToGlobal(pos))
            menu.close()

    def processInsert(self,behavior=InsertBehavior.INSERT_FIRST):
        indexes = self.tableViewProduct.selectionModel().selectedIndexes()
        if behavior==InsertBehavior.INSERT_FIRST:
            row=0
        elif behavior==InsertBehavior.INSERT_LAST:
            row = self.tableViewProduct.model().rowCount(QModelIndex())+1
        else:
            if indexes:
                index=indexes[0]
                row=index.row()
                if behavior==InsertBehavior.INSERT_ABOVE:
                    row=max(row,0)
                else:
                    size = self.tableViewProduct.model().rowCount(QModelIndex())
                    row = min(row + 1, size)
        self.tableViewProduct.model().insertRows(row, 1, QModelIndex())
    def processDelete(self):
        indexes = self.tableViewProduct.selectionModel().selectedIndexes()
        if indexes:
                index=indexes[0]
                row = index.row()
                self.tableViewProduct.model().removeRows(row, 1, QModelIndex())

    def processExportJson(self):
        # setup for QFileDialog
        filters = "Dataset (*.json);;All files(*)"
        filename, selected_filter = QFileDialog.getSaveFileName(
            self.MainWindow,
            filter=filters,
        )
        #self.fileFactory.writeData(filename,self.model.data)
        with open(filename, 'w') as jf:
            jsonString = json.dumps(self.model.data)
            jsonFile = open(filename, "w")
            jsonFile.write(jsonString)
            jsonFile.close()
    def processImportJson(self):
        # setup for QFileDialog
        filters = "Dataset (*.json);;All files(*)"
        filename, selected_filter = QFileDialog.getOpenFileName(
            self.MainWindow,
            filter=filters,
        )
        with open(filename, 'r') as jf:
            lines = jf.readline()
            print(lines)
            self.data= json.loads(lines)
            self.model.data= self.data
        self.model.layoutChanged.emit()
    def show(self):
        self.MainWindow.show()

Bước 7: Cuối cùng ta tạo “MyApp.py” để thực thi chương trình

from PyQt6.QtWidgets import QApplication, QMainWindow

from MainWindowEx import MainWindowEx

app=QApplication([])
myWindow=MainWindowEx()
myWindow.setupUi(QMainWindow())
myWindow.show()
app.exec()

Chạy chương trình lên ta có kết quả như mong muốn:

Như vậy là tới đây Tui đã hướng dẫn đầy đủ và chi tiết kiến trúc Model View và áp dụng cho QTableView trong quản lý Product. Với đầy đủ tính năng: Xem, thêm, sửa, xóa. Đặc biệt là các chức năng Context Menu để hỗ trợ việc chèn dữ liệu cũng như xóa dữ liệu khởi QTableView, và Menu để Export và Import dữ liệu với định dạng JSon Array.

Soure code đầy đủ của dự án các bạn tải ở đây:

https://www.mediafire.com/file/jxdipo7auiz7tdl/LearnModelViewPart4.rar/file

Bài học sau Tui sẽ hướng dẫn các bạn cách Nạp dữ liệu từ SQLite vào QTableView dùng kiến trúc Model View.

Chúc các bạn thành công

Bài 28: Kiến trúc Model View-PyQt6–Part 3

Ở các bài trước chúng ta đã ứng dụng thành thạo kiến trúc Model View để tương tác dữ liệu Employee trên QListview. Trong bài này Tui tiếp tục hướng dẫn các bạn cách dùng Model View cho QTableView để hiển thị dữ liệu dạng bảng. Bạn có thể nạp Ma trận dữ liệu vào QTableView hoặc nạp Danh sách dữ liệu đối tượng dạng List vào QTableView. Sự khác biệt giữa QListView và QTableView là QListView không cần dùng các cột dữ liệu, còn QTableView cần dùng các cột dữ liệu để chi tiết hóa các thuộc tính của đối tượng.

Trong bài này Tui minh họa ví dụ đơn giản về nạp Ma trận Product lên QTableView dùng kiến trúc Model View. Giả sử ta có Ma trận dữ liệu như dưới đây:

data = [["p1", "Coca", 100],
        ["p2", "Pepsi", 50],
        ["p3", "Sting", 300],
        ["p4", "Aqua", 70],
        ["p5", "Redbull", 200],
        ["p6", "", 120]]
columns = ["ID", "Name", "Price"]

Làm thế nào để nạp ma trận “data” lên QTableView cùng với các cột được lưu trong mảng “columns“? Đồng thời nếu giá <100 thì tô chữ đỏ, nếu tên rỗng thì tô nền vàng:

Nếu hiểu và triển khai được bài này thì bạn có thể dễ dàng nạp ma trận dữ liệu cần phân tích lên giao diện, ví dụ ma trận về lịch sử kinh doanh.

Các bạn tiến hành làm từng bước như Tui hướng dẫn dưới đây.

Bước 1: Tạo dự án “LearnModelViewPart3” trong Pycharm có cấu trúc như dưới đây:

  • Trung tâm của dự án chính là lớp “TableModel.py“. Lớp này kế thừa từ “QAbstractTableModel” để thực hiện kiến trúc Model View. Tui sẽ trình bày chi tiết ở bước sau
  • MainWindow.ui” là giao diện thiết kế của phần mềm bằng Qt Designer
  • MainWindow.py” là generate python code của giao diện “MainWindow.ui”
  • MainWindowEx.py” là lớp kế thừa từ generate python code “MainWindow.py” nó được dùng để nạp giao diện, xử lý sự kiện người dùng, gán model view mà không bị ảnh hưởng khi ta tiếp tục generate code từ giao diện khi thay đổi.
  • MyApp.py” là file mã lệnh thực thi chương trình

Bước 2: Tạo lớp mã lệnh “TableModel.py” như dưới đây:

from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QAbstractTableModel


class TableModel(QAbstractTableModel):
    def __init__(self, data,columns):
        super().__init__()
        self.data = data
        self.columns=columns

    def data(self, index, role):
        value=self.data[index.row()][index.column()]
        if role == Qt.ItemDataRole.DisplayRole:
            return value
        if role==Qt.ItemDataRole.BackgroundRole:
            if index.column()==1 and value=="":
               return QtGui.QColor(Qt.GlobalColor.yellow)
        if role==Qt.ItemDataRole.ForegroundRole:
            if index.column() == 2 and value<100:
                return QtGui.QColor(Qt.GlobalColor.red)
    def rowCount(self, index):
        return len(self.data)

    def columnCount(self, index):
        return len(self.columns)

    def headerData(self, section, orientation, role):
        if role == Qt.ItemDataRole.DisplayRole:
            if orientation == Qt.Orientation.Horizontal:
                return str(self.columns[section])
            if orientation==Qt.Orientation.Vertical:
                return  str(section+1)

Tui giải thích chi tiết các hàm của TableModel.py như sau:

  • Constructor __init__(self, data,columns) nhận vào 2 đối số. Đối số data là ma trận dữ liệu Product đã mô tả ở trên, đối số columns là mảng các cột của Product.
  • Hàm data(self, index, role) được override để hiển thị dữ liệu cũng như các định dạng dữ liệu (màu nền, màu chữ). Trong hàm này Tui có sử dụng 3 Role:
    1. Qt.ItemDataRole.DisplayRole (dùng để hiển thị dữ liệu thông thường)
    2. Qt.ItemDataRole.BackgroundRole (để hiển thị màu nền, trong trường hợp này là nếu dữ liệu của cột tên Product là rỗng thì ta tô nền vàng)
    3. Qt.ItemDataRole.ForegroundRole (để hiển thị màu chữ, trong trường hợp này là nếu dữ liệu của cột giá Product nhỏ hơn 100 thì tô chữ đỏ)
  • Hàm rowCount(self, index) được override để trả về số dòng dữ liệu trong model
  • Hàm columnCount(self, index) được override để trả về số cột trong model
  • Hàm headerData(self, section, orientation, role) được override để vẽ tiêu đề của Cột nằm đứng và header của dòng nằm ngang.

5 Hàm bắt buộc cần được định nghĩa đối với TableModel ở trên. Tương tự cho các loại dữ liệu khác thì bạn cũng tạo 5 hàm này, và điều chỉnh mã lệnh theo nhu cầu sử dụng khác nhau. Còn những hàm nâng cao khác để hỗ trợ thêm mới, chỉnh sửa, cũng như xóa dữ liệu Tui sẽ trình bày chi tiết ở bài học sau.

Bước 3: Thiết kế giao diện “MainWindow.ui” và đặt tên cho widget như hình dưới đây:

Bài này thì ta chỉ cần kéo 1 QTableView ra mà thôi.

Bước 4: Generate python code “MainWindow.py” cho giao diện “MainWindow.ui”

# Form implementation generated from reading ui file 'MainWindow.ui'
#
# Created by: PyQt6 UI code generator 6.4.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(335, 297)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.tableViewProduct = QtWidgets.QTableView(parent=self.centralwidget)
        self.tableViewProduct.setGeometry(QtCore.QRect(10, 10, 301, 231))
        self.tableViewProduct.setObjectName("tableViewProduct")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 335, 26))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Trần Duy Thanh - QTableView-ModelView"))

Bước 5: Tạo “MainWindowEx.py” kế thừa từ Generate Python code ở trên để nạp giao diện và gán Model View

from MainWindow import Ui_MainWindow
from TableModel import TableModel


class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        super().__init__()
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow
        data = [["p1", "Coca", 100],
                ["p2", "Pepsi", 50],
                ["p3", "Sting", 300],
                ["p4", "Aqua", 70],
                ["p5", "Redbull", 200],
                ["p6", "", 120]]
        columns = ["ID", "Name", "Price"]
        self.model = TableModel(data, columns)
        self.tableViewProduct.setModel(self.model)
    def show(self):
        self.MainWindow.show()

Dòng lệnh 18:

self.model = TableModel(data, columns)

Dòng lệnh 18 này dùng để khởi tạo đối tượng tableModel, nó nhận vào Ma trận Product và mảng columns

Dòng lệnh 19:

self.tableViewProduct.setModel(self.model)

Dòng lệnh 19 gán model cho QTableView.

Lúc này các hàm trong TableModel sẽ tự động được thực hiện, rất chuyên nghiệp và ảo ma canada.

Bước 6: Tạo file lệnh “MyApp.py” để thực thi chương trình

from PyQt6.QtWidgets import QApplication, QMainWindow

from MainWindowEx import MainWindowEx

app=QApplication([])
myWindow=MainWindowEx()
myWindow.setupUi(QMainWindow())
myWindow.show()
app.exec()

Chạy MyApp.py ta có kết quả như mong muốn:

Bạn quan sát thấy cột Price sẽ tô màu đỏ những Price nào <100, như vậy giá 50 và 70 tự động được tô màu đỏ. Và cột Name có dữ liệu nào rỗng sẽ tô nền vàng.

Như vậy Tui đã trình bày xong cách ứng dụng kiến trúc Model View vào quản lý và hiển thị dữ liệu dạng Ma trận, đã trình bày kỹ cách tạo TableModel kế thừa từ QAbstractTableModel. Cũng như đã trình bày chi tiết ý nghĩa và cách lập trình các hàm quan trọng trong lớp này để ta có thể dễ dàng tùy chỉnh cách thức mà dữ liệu hiển thị trên giao diện như mong muốn.

Source code đây đủ của bài này các bạn tải ở đây:

https://www.mediafire.com/file/o3s1ytr0fi1hn0w/LearnModelViewPart3.rar/file

Bài học sau Tui sẽ mở rộng bài này bằng cách bổ sung các chức năng: Thêm, Sửa, Xóa dữ liệu ngay trên giao diện QTableView để các bạn củng cố hơn nữa cách ứng dụng kiến trúc Model View cũng như nhìn thấy được lợi ích của mô hình này. Các bạn chú ý theo dõi

Chúc các bạn thành công.

Bài 27: Kiến trúc Model View-PyQt6–Part 2

Bài 26 Tui đã trình bày chi tiết về kiến trúc Model View trong PyQt6 cùng với một minh họa về hiển thị danh sách Employee lên QListView. Trong bài này Tui sẽ tiếp tục mở rộng bài minh họa về Employee, bằng cách bổ sung các chức năng: Thêm, Sửa, Xóa model cũng như viết mã lệnh hiệu chỉnh định dạng font chữ, màu chữ cho các item trên QListView bằng kiến trúc Model View. Đặc biệt Tui cung cấp thêm 3 menu item để serialize và deserialize dữ liệu JSon Array cho danh sách Employee, và menu item Exit.

Mô tả chi tiết chức năng của phần mềm:

  • Chương trình dùng để quản lý Employee, thông tin của Employee bao gồm: id, name, age. Chương trình sử dụng kiến trúc Model View
  • Dữ liệu hiển thị lên QListView có 2 dạng: Nếu Employee có age>=18 thì có biểu tượng icon màu xanh. Nếu age<18 thì có biểu tượng icon màu đỏ, chữ đỏ và nền vàng.
  • Nút “New”: Chương trình sẽ xóa dữ liệu trên QLineEdit và focus tới ô ID
  • Nút “Save”: Có 2 chức năng là lưu mới và lưu cập nhật, nếu Employee đang chọn trên QListView thì chương trình sẽ cập nhật, còn nếu không chọn Employee trên QListView thì chương trình sẽ lưu mới. Nếu lưu thành công dữ liệu sẽ hiển thị lên QListView
  • Nút “Delete”: Chương trình sẽ xóa Employee đang chọn trên QListView, có hiển thị cửa sổ xác nhận muốn xóa hay không.
  • Menu item “Export to Json”: Chương trình sẽ mở cửa sổ SaveFileDialog để người dùng xuất dữ liệu Employee có định dạng JSON ARRY ra ổ cứng bất kỳ
  • Menu item “Import from Json”: Chương trình sẽ mở cửa sổ OpenFileDialog để người dùng chọn file JSON ARRAY đã lưu và hiểu thị danh sách Employee trong file này lên QListView.
  • Menu item “Exit”: Chương trình sẽ thoát

Bây giờ chúng ta đi vào chi tiết từng bước:

Bước 1: Tạo dự án “LearnModelViewPart2” trong Pycharm như cấu trúc dưới đây:

  • Thư mục “images” lưu trữ các hình ảnh và icon của phần mềm
  • “Employee.py” là file mã lệnh python cho mô hình lớp Employee có các thuộc tính id, name age
  • “EmployeeModel.py” là lớp Model kế thừa từ QAbstractListModel để sử dụng trong mô hình Model View nhằm hiển thị dữ liệu danh sách Employee.
  • “FileFactory.py” là file mã lệnh Python để Serialize và Deserialize dữ liệu Employee với định dạng Json Array
  • “MainWindow.ui” là file giao diện của phần mềm, sử dụng Qt Designer tích hợp trong Pycharm để thiết kế.
  • “MainWindow.py” là file Generate Python code của giao diện MainWindow.ui
  • “MainWindowEx.py” là file mã lệnh python để xử lý sự kiện người dùng cũng như gán Model View
  • “MyApp.py” là file mã lệnh python để thực thi chương trình.

Bước 2: Viết mã lệnh cho Employee.py

class Employee:
    def __init__(self,id,name,age):
        self.id=id
        self.name=name
        self.age=age
    def __str__(self):
        return str(self.id)+"-"+self.name+"-"+str(self.age)

Lớp Employee ở trên có 3 thuộc tính: id, name và age

hàm __str__ để hiển thị chuỗi dữ liệu format cho đối tượng Employee, tùy nhu cầu mà bạn có thể chỉnh sửa hàm này.

Bước 3: Viết mã lệnh cho “EmployeeModel.py”

import typing

from PyQt6 import QtGui
from PyQt6.QtCore import QAbstractListModel, Qt, QModelIndex
from PyQt6.QtGui import QImage


class EmployeeModel(QAbstractListModel):
    def __init__(self,employees=None):
        super().__init__()
        self.employees=employees
    def data(self, index: QModelIndex, role: int = ...) -> typing.Any:
        emp = self.employees[index.row()]
        if role == Qt.ItemDataRole.DisplayRole:
            return str(emp)
        if role==Qt.ItemDataRole.DecorationRole:
            if emp.age<18:
                return QImage("images/ic_notvalid.png")
            else:
                return QImage("images/ic_valid.png")
        if role==Qt.ItemDataRole.ForegroundRole:
            if emp.age < 18:
                return QtGui.QColor(Qt.GlobalColor.red)
        if role==Qt.ItemDataRole.BackgroundRole:
            if emp.age < 18:
                return QtGui.QColor(Qt.GlobalColor.yellow)
    def rowCount(self, parent: QModelIndex = ...) -> int:
        return len(self.employees)

Nếu Employee có age>=18 thì có biểu tượng icon màu xanh. Nếu age<18 thì có biểu tượng icon màu đỏ, chữ đỏ và nền vàng.

Ở đây Tui giải thích thêm hàm override data(), nó dùng để hiển thị dữ liệu theo từng trường hợp đặc biệt:

RoleValueÝ nghĩa chức năng
Qt.ItemDataRole.DisplayRole0Dùng để hiển thị dữ liệu, kiểu QString.
Qt.ItemDataRole.DecorationRole1Dùng để hiển thị định dạng trang trí, biểu tượng, kiểu QColor, QIcon hoặc QPixmap
Qt.ItemDataRole.EditRole2Dùng để hiển thị khi dữ liệu chỉnh sửa, kiểu QString
Qt.ItemDataRole.ToolTipRole3Dùng để hiển thị Tooltip, kiểu QString
Qt.ItemDataRole.StatusTipRole4Dùng để hiển thị cho staus bar, kiểu QString
Qt.ItemDataRole.WhatsThisRole5Dùng để hiển thị “What’s this”, kiểu QString
Qt.ItemDataRole.FontRole6Dùng để hiển thị Font chữ
Qt.ItemDataRole.TextAlignmentRole7Dùng để căn lề chữ
Qt.ItemDataRole.BackgroundRole8Dùng để hiển thị màu nền
Qt.ItemDataRole.ForegroundRole9Dùng để hiển thị màu chữ
Qt.ItemDataRole.CheckStateRole10Dùng để kiểm tra trạng thái
Qt.ItemDataRole.AccessibleTextRole11Dùng để truy suất text
Qt.ItemDataRole.AccessibleDescriptionRole12Dùng để truy suất mô tả
Qt.ItemDataRole.SizeHintRole13Dùng để thiết lập size hint cho item
Qt.ItemDataRole.InitialSortOrderRole14Dùng để khởi tạo sắp xếp
Qt.ItemDataRole.UserRole15Dùng để xử lý dữ liệu object trong item

Bước 3: Viết mã lệnh cho FileFactory.py

import json
import os

class FileFactory:
    #path: path to serialize array of Employee
    #arrData: array of Employee
    def writeData(self,path,arrData):
        jsonString = json.dumps([item.__dict__ for item in arrData],default=str)
        jsonFile = open(path, "w")
        jsonFile.write(jsonString)
        jsonFile.close()
    #path: path to deserialize array of Employee
    #ClassName: Employee
    def readData(self,path,ClassName):
        if os.path.isfile(path) == False:
            return []
        file = open(path, "r")
        # Reading from file
        self.arrData = json.loads(file.read(), object_hook=lambda d: ClassName(**d))
        file.close()
        return self.arrData

Lớp FileFactory này các bạn đã làm quen nhiều lần ở các bài học trước, nhiệm vụ của writeData là Serialize danh sách Employee xuống ổ cứng với định dạng JSON Array. Hàm readData để Deserialize chuỗi JSON Array dưới ổ cứng lên bộ nhớ bằng mô hình hóa hướng đối tượng các Employee.

Bước 4: Thiết kế giao diện MainWindow.ui bằng Qt Designer (Hoặc Qt Creator)

Ta kéo thả các Widget và đặt tên cho chúng như hình tổng quan dưới đây:

Các widget QLineEdit, QPushButton, QListView các bạn đã làm nhiều lần rồi nên Tui không trình bày lại, các bạn chỉ cần kéo thả ra giao diện và đặt tên như trên là được.

Tui sẽ trình bày Menu vì bạn chưa được học. Cấu trúc Menu của PyQt6 gồm:

  • Thanh nằm ngang chứa các menu gọi là QMenuBar
  • Bên trong QMenu chứa các Menu được gọi là QMenu
  • Bên trong QMenu khi nhấn vào nó xổ danh sách xuống gọi là các QAction

Tức với giao diện ở trên thì:

  • Thanh nằm ngang ở trên cùng là QMenuBar
  • System” là QMenu
  • Export to JSon” là QAction
  • Import from JSon” là QAction
  • Exit” là QAction

Và signal ta dùng cho các QAction là triggered

Ta tiếp tục thiết kế QMenuBar nhé. Nhìn vào nhóm Menu Bar ở bên trên giao diện, ngay chỗ “Type Here” bạn gõ vào System và nhấn Enter thì ta có kết quả như hình bên dưới:

Tiếp tục chỗ “Type Here” ở hình trên ta gõ vào “Export to JSon” rồi nhấn Enter để tạo QAction, ta có kết quả:

Để tạo thanh nằm ngang ngăn cách các QAction ta nhấn “Add Separator”, kết quả sẽ xuất hiện đường nằm ngang:

Tiếp tục, chỗ “Type Here” ta gõ “Import from JSon” để tạo QAction rồi nhấn Enter, kết quả:

Để tạo thêm đường ngang giữa các QAction ta nhấn “Add Separator”, kết quả:

Cuối cùng ta gõ “Exit” vào mục Type Here, kết quả:

Sau khi tạo xong các QAction cho QMenu ta tiến hành gán các Icon cho các QAction để nó thẩm mỹ hơn, để tạo Icon thì ta cứ chọn QAction trước, sau đó trong thuộc tính Icon ta chọn “Choose File…” để trỏ tới hình mình mong muốn:

Cứ như vậy, các bạn lặp lại thao tác gán Icon cho các QAction còn lại, kết quả cuối cùng:

Bước 5: Generate Python code “MainWindow.py” cho giao diện “MainWindow.ui“. Cách Generate code đã được học kỹ ở các bài đầu tiên

# Form implementation generated from reading ui file 'MainWindow.ui'
#
# Created by: PyQt6 UI code generator 6.4.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(361, 349)
        icon = QtGui.QIcon()
        icon.addPixmap(QtGui.QPixmap("images/ic_logo.jpg"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        MainWindow.setWindowIcon(icon)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.listViewEmployee = QtWidgets.QListView(parent=self.centralwidget)
        self.listViewEmployee.setGeometry(QtCore.QRect(10, 10, 331, 161))
        self.listViewEmployee.setObjectName("listViewEmployee")
        self.label = QtWidgets.QLabel(parent=self.centralwidget)
        self.label.setGeometry(QtCore.QRect(20, 180, 81, 16))
        self.label.setObjectName("label")
        self.lineEditId = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditId.setGeometry(QtCore.QRect(130, 180, 201, 22))
        self.lineEditId.setObjectName("lineEditId")
        self.lineEditName = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditName.setGeometry(QtCore.QRect(130, 210, 201, 22))
        self.lineEditName.setObjectName("lineEditName")
        self.label_2 = QtWidgets.QLabel(parent=self.centralwidget)
        self.label_2.setGeometry(QtCore.QRect(20, 210, 101, 16))
        self.label_2.setObjectName("label_2")
        self.label_3 = QtWidgets.QLabel(parent=self.centralwidget)
        self.label_3.setGeometry(QtCore.QRect(20, 240, 101, 16))
        self.label_3.setObjectName("label_3")
        self.lineEditAge = QtWidgets.QLineEdit(parent=self.centralwidget)
        self.lineEditAge.setGeometry(QtCore.QRect(130, 240, 201, 22))
        self.lineEditAge.setObjectName("lineEditAge")
        self.pushButtonNew = QtWidgets.QPushButton(parent=self.centralwidget)
        self.pushButtonNew.setGeometry(QtCore.QRect(40, 270, 71, 28))
        icon1 = QtGui.QIcon()
        icon1.addPixmap(QtGui.QPixmap("images/ic_new.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.pushButtonNew.setIcon(icon1)
        self.pushButtonNew.setIconSize(QtCore.QSize(24, 24))
        self.pushButtonNew.setObjectName("pushButtonNew")
        self.pushButtonSave = QtWidgets.QPushButton(parent=self.centralwidget)
        self.pushButtonSave.setGeometry(QtCore.QRect(150, 270, 71, 28))
        icon2 = QtGui.QIcon()
        icon2.addPixmap(QtGui.QPixmap("images/ic_save.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.pushButtonSave.setIcon(icon2)
        self.pushButtonSave.setIconSize(QtCore.QSize(24, 24))
        self.pushButtonSave.setObjectName("pushButtonSave")
        self.pushButtonDelete = QtWidgets.QPushButton(parent=self.centralwidget)
        self.pushButtonDelete.setGeometry(QtCore.QRect(260, 270, 71, 28))
        icon3 = QtGui.QIcon()
        icon3.addPixmap(QtGui.QPixmap("images/ic_delete.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.pushButtonDelete.setIcon(icon3)
        self.pushButtonDelete.setObjectName("pushButtonDelete")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 361, 26))
        self.menubar.setObjectName("menubar")
        self.menuSystem = QtWidgets.QMenu(parent=self.menubar)
        self.menuSystem.setLayoutDirection(QtCore.Qt.LayoutDirection.LeftToRight)
        self.menuSystem.setTearOffEnabled(False)
        self.menuSystem.setSeparatorsCollapsible(False)
        self.menuSystem.setToolTipsVisible(False)
        self.menuSystem.setObjectName("menuSystem")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)
        self.actionExport_to_JSon = QtGui.QAction(parent=MainWindow)
        icon4 = QtGui.QIcon()
        icon4.addPixmap(QtGui.QPixmap("images/ic_export.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionExport_to_JSon.setIcon(icon4)
        self.actionExport_to_JSon.setObjectName("actionExport_to_JSon")
        self.actionImport_from_JSon = QtGui.QAction(parent=MainWindow)
        icon5 = QtGui.QIcon()
        icon5.addPixmap(QtGui.QPixmap("images/ic_import.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionImport_from_JSon.setIcon(icon5)
        self.actionImport_from_JSon.setObjectName("actionImport_from_JSon")
        self.actionExit = QtGui.QAction(parent=MainWindow)
        icon6 = QtGui.QIcon()
        icon6.addPixmap(QtGui.QPixmap("images/ic_exit.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
        self.actionExit.setIcon(icon6)
        self.actionExit.setObjectName("actionExit")
        self.menuSystem.addAction(self.actionExport_to_JSon)
        self.menuSystem.addSeparator()
        self.menuSystem.addAction(self.actionImport_from_JSon)
        self.menuSystem.addSeparator()
        self.menuSystem.addAction(self.actionExit)
        self.menubar.addAction(self.menuSystem.menuAction())

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Trần Duy Thanh - Model View"))
        self.label.setText(_translate("MainWindow", "Employee ID:"))
        self.label_2.setText(_translate("MainWindow", "Employee Name:"))
        self.label_3.setText(_translate("MainWindow", "Age of Employee:"))
        self.pushButtonNew.setText(_translate("MainWindow", "New"))
        self.pushButtonSave.setText(_translate("MainWindow", "Save"))
        self.pushButtonDelete.setText(_translate("MainWindow", "Delete"))
        self.menuSystem.setTitle(_translate("MainWindow", "System"))
        self.actionExport_to_JSon.setText(_translate("MainWindow", "Export to JSon"))
        self.actionImport_from_JSon.setText(_translate("MainWindow", "Import from JSon"))
        self.actionExit.setText(_translate("MainWindow", "Exit"))

Bước 6: Viết mã lệnh “MainWindowEx.py“, mã lệnh trong này kế thừa từ lớp được Generate trong bước trước để xử lý sự kiện người dùng và nó giúp ta không bị ảnh hưởng khi trong tương lai Generate lại python code.

Constructor của lớp này Tui định nghĩa 2 biến đối tượng như dưới đây:

from PyQt6.QtWidgets import QFileDialog, QMessageBox
from Employee import Employee
from EmployeeModel import EmployeeModel
from FileFactory import FileFactory
from MainWindow import Ui_MainWindow

class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        self.employees=[]
        self.selectedEmployee=None
        self.fileFactory=FileFactory()
  • Biến employees để lưu danh sách đối tượng Employee
  • Biến selectedEmployee để lưu Employee hiện tại đang lựa chọn trên giao diện QListView, nó được dùng để xử lý lưu mới hay lưu cập nhật Employee
  • Biến fileFactory là đối tượng để Serialize và Deserialize danh sách Employee với định dạng JSON ARRAY

Tiếp theo là hàm setupUi() được override để xử lý nạp giao diện cũng như xử lý signal mà người dùng tương tác:

def setupUi(self, MainWindow):
    super().setupUi(MainWindow)
    self.MainWindow=MainWindow
    self.model=EmployeeModel(self.employees)
    self.listViewEmployee.setModel(self.model)

    self.pushButtonNew.clicked.connect(self.processNew)
    self.pushButtonSave.clicked.connect(self.processSave)
    #self.listViewEmployee.clicked.connect(self.processClicked)
    self.listViewEmployee.selectionModel().selectionChanged.connect(self.processSelection)
    self.pushButtonDelete.clicked.connect(self.processDelete)
    self.actionExport_to_JSon.triggered.connect(self.processExportJson)
    self.actionImport_from_JSon.triggered.connect(self.processImportJson)

Mã lệnh xử lý các Signal ở trên có 2 cái mới:

  • Xử lý sự kiện người dùng lựa chọn trên QListView (Bao gồm cả click chuột và di chuyển phím) ta dùng singal selectionChanged nằm trong selectionModel()
  • Xử lý sự kiện người dùng lựa chọn QAction ta dùng singal triggered

Hàm processNew() sẽ xóa các dữ liệu trên QLineEdit và focus vào ô ID để giúp người dùng nhập liệu nhanh chóng, đồng thời gán selectedEmployee=None để đánh dấu đây là khởi đầu cho thao tác lưu mới một Employee.

def processNew(self):
    self.lineEditId.setText("")
    self.lineEditName.setText("")
    self.lineEditAge.setText("")
    self.lineEditId.setFocus()
    self.selectedEmployee=None

Tiếp theo là hàm processSave().

def processSave(self):
    id=self.lineEditId.text()
    name=self.lineEditName.text()
    age=int(self.lineEditAge.text())
    emp=Employee(id,name,age)
    if self.selectedEmployee==None:
        self.employees.append(emp)
        self.selectedEmployee=emp
    else:
        index=self.employees.index(self.selectedEmployee)
        self.selectedEmployee=emp
        self.employees[index]=self.selectedEmployee
    self.model.layoutChanged.emit()

Hàm Save sẽ có 2 chức năng là lưu mới và lưu cập nhật Employee.

Nếu selectedEmployee = None thì chương trình lưu mới, còn khác None thì chương trình lưu cập nhật.

Sau khi dữ liệu được cập nhật trong biến employees, lúc này ta sẽ nói ModelView cập nhật lại giao diện bằng hàm layoutChanged.emit()

Tiếp theo là hàm processSelection(). Hàm này sẽ lấy dòng dữ liệu mà người dùng chọn trên QListView bằng hàm selectedIndexes(). Hàm selectedIndexes() trả về danh sách các QIndex được lựa chọn, ở đây ta chỉ xử lý chọn 1 dòng dữ liệu, do đó ta có indexes[0].row() là trả về vị trí kiểu integer để lấy chính xác đối tượng Employee tại vị trí đang chọn:

def processSelection(self):
    indexes=self.listViewEmployee.selectedIndexes()
    if indexes:
        row=indexes[0].row()
        emp=self.employees[row]
        self.lineEditId.setText(str(emp.id))
        self.lineEditName.setText(emp.name)
        self.lineEditAge.setText(str(emp.age))
        self.selectedEmployee=emp

Sau khi có đối tượng Employee đang chọn, ta hiển thị lên các QLineEdit đồng thời gán biến selectedEmployee cho biến đối tượng này, mục đích để hỗ trợ cho thao tác lưu cập nhật, ví dụ dưới đây là người dùng chọn Vitor:

Tiếp tới nữa là hàm “processDelete()“, hàm này tiến hành xóa Employee đang chọn trên QListView:

def processDelete(self):
    dlg = QMessageBox(self.MainWindow)
    if self.selectedEmployee == None:
        dlg.setWindowTitle("Deleteing error")
        dlg.setIcon(QMessageBox.Icon.Critical)
        dlg.setText("You have to select a Product to delete")
        dlg.exec()
        return
    dlg.setWindowTitle("Confirmation Deleting")
    dlg.setText("Are you sure you want to delete?")
    buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
    dlg.setStandardButtons(buttons)
    button = dlg.exec()
    if button == QMessageBox.StandardButton.Yes:
        self.employees.remove(self.selectedEmployee)
        self.model.layoutChanged.emit()
        self.selectedEmployee = None
        self.processNew()

Lệnh trên Tui tiếp tục dùng QMessage dialog để xác thực xem người dùng có thực sự muốn xóa hay không, chọn Yes thì xóa:

tương tự như thao tác lưu mới hay cập nhật, thì thao tác xóa cũng gọi hàm emit() để model view cập nhật lại giao diện.

Để Serialize và Deserialize dữ liệu ra JSON ARRAY ta có 2 hàm được Tui viết dưới đây.

Thứ nhất là hàm processExportJson() dùng để xuất toàn bộ dữ liệu Employee ra JSON ARRAY:

def processExportJson(self):
    # setup for QFileDialog
    filters = "Dataset (*.json);;All files(*)"
    filename, selected_filter = QFileDialog.getSaveFileName(
        self.MainWindow,
        filter=filters,
    )
    self.fileFactory.writeData(filename,self.employees)

Mã lệnh trên dùng QFileDialog mà dạng SaveFile, lúc này chương trình sẽ hiển thị cửa sổ lưu file cho ta lựa chọn nơi lưu trữ tùy ý.

Ta vào menu System rồi chọn “Export to JSon”:

Sau đó chọn nơi lưu trữ, đặt tên file và nhấn nút Save:

Sau khi lưu thành công ta có cấu dữ liệu của database.json như sau:

[{"id": "1", "name": "Peter", "age": 25}, {"id": "2", "name": "John", "age": 20}, {"id": "3", "name": "Tom", "age": 17}, {"id": "4", "name": "Bjorn", "age": 24}, {"id": "5", "name": "Vitor", "age": 27}, {"id": "6", "name": "Manuel", "age": 16}, {"id": "7", "name": "Daisy", "age": 20}]

Cuối cùng là hàm “processImportJson()” dùng để đọc dữ liệu từ JSON và phục hồi lên bộ nhớ đồng thời đưa về mô hình hóa hướng đối tượng và hiển thị lên QListView:

def processImportJson(self):
    # setup for QFileDialog
    filters = "Dataset (*.json);;All files(*)"
    filename, selected_filter = QFileDialog.getOpenFileName(
        self.MainWindow,
        filter=filters,
    )
    arr=self.fileFactory.readData(filename,Employee)

    self.employees.clear()

    for i in range(len(arr)):
        self.employees.append(arr[i])
    self.model.layoutChanged.emit()

Mã lệnh ở trên sẽ dùng QFileDialog.getOpenFileName để mở cửa sổ File Dialog, cho người dùng lựa chọn file dữ liệu để nạp lên giao diện.

Sau khi nạp xong dữ liệu lên bộ nhớ, ta gọi hàm self.model.layoutChanged.emit() để hiển thị dữ liệu lên giao diện:

Từ giao diện trống, ta chọn “Import from JSon”, sau đó chọn file “database.json” rồi bấm “Open”, ta sẽ có kết quả như mong muốn, các Emloyee được nạp lên QListView.

Dưới đây là mã lệnh đầy đủ của “MainWindowEx.py”:

from PyQt6.QtWidgets import QFileDialog, QMessageBox
from Employee import Employee
from EmployeeModel import EmployeeModel
from FileFactory import FileFactory
from MainWindow import Ui_MainWindow

class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        self.employees=[]
        self.selectedEmployee=None
        self.fileFactory=FileFactory()
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow
        self.model=EmployeeModel(self.employees)
        self.listViewEmployee.setModel(self.model)

        self.pushButtonNew.clicked.connect(self.processNew)
        self.pushButtonSave.clicked.connect(self.processSave)
        #self.listViewEmployee.clicked.connect(self.processClicked)
        self.listViewEmployee.selectionModel().selectionChanged.connect(self.processSelection)
        self.pushButtonDelete.clicked.connect(self.processDelete)
        self.actionExport_to_JSon.triggered.connect(self.processExportJson)
        self.actionImport_from_JSon.triggered.connect(self.processImportJson)

    def processNew(self):
        self.lineEditId.setText("")
        self.lineEditName.setText("")
        self.lineEditAge.setText("")
        self.lineEditId.setFocus()
        self.selectedEmployee=None
    def processSave(self):
        id=self.lineEditId.text()
        name=self.lineEditName.text()
        age=int(self.lineEditAge.text())
        emp=Employee(id,name,age)
        if self.selectedEmployee==None:
            self.employees.append(emp)
            self.selectedEmployee=emp
        else:
            index=self.employees.index(self.selectedEmployee)
            self.selectedEmployee=emp
            self.employees[index]=self.selectedEmployee
        self.model.layoutChanged.emit()
    def processSelection(self):
        indexes=self.listViewEmployee.selectedIndexes()
        if indexes:
            row=indexes[0].row()
            emp=self.employees[row]
            self.lineEditId.setText(str(emp.id))
            self.lineEditName.setText(emp.name)
            self.lineEditAge.setText(str(emp.age))
            self.selectedEmployee=emp
    def processDelete(self):
        dlg = QMessageBox(self.MainWindow)
        if self.selectedEmployee == None:
            dlg.setWindowTitle("Deleteing error")
            dlg.setIcon(QMessageBox.Icon.Critical)
            dlg.setText("You have to select a Product to delete")
            dlg.exec()
            return
        dlg.setWindowTitle("Confirmation Deleting")
        dlg.setText("Are you sure you want to delete?")
        buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        dlg.setStandardButtons(buttons)
        button = dlg.exec()
        if button == QMessageBox.StandardButton.Yes:
            self.employees.remove(self.selectedEmployee)
            self.model.layoutChanged.emit()
            self.selectedEmployee = None
            self.processNew()
    def processExportJson(self):
        # setup for QFileDialog
        filters = "Dataset (*.json);;All files(*)"
        filename, selected_filter = QFileDialog.getSaveFileName(
            self.MainWindow,
            filter=filters,
        )
        self.fileFactory.writeData(filename,self.employees)
    def processImportJson(self):
        # setup for QFileDialog
        filters = "Dataset (*.json);;All files(*)"
        filename, selected_filter = QFileDialog.getOpenFileName(
            self.MainWindow,
            filter=filters,
        )
        arr=self.fileFactory.readData(filename,Employee)

        self.employees.clear()

        for i in range(len(arr)):
            self.employees.append(arr[i])
        self.model.layoutChanged.emit()
    def show(self):
        self.MainWindow.show()

Bước 7: Cuối cùng ta viết mã lệnh “MyApp.py” để thực thi chương trình:

from PyQt6.QtWidgets import QApplication, QMainWindow

from MainWindowEx import MainWindowEx

app=QApplication([])
myWindow=MainWindowEx()
myWindow.setupUi(QMainWindow())
myWindow.show()
app.exec()

Thực thi chương trình ta có kết quả như mong muốn:

Như vậy Tui đã trình bày chi tiết và kỹ lưỡng xong phần ứng dụng kiến trúc Model View để xử lý dữ liệu với QListView. Hướng dẫn các bạn cách dùng QMenuBar, QMenu, QAction cũng như các signal triggered tương ứng, đã minh họa đầy đủ và chi tiết các chức năng xem, thêm, sửa, xóa dữ liệu. Các bạn chú ý lập trình lại bài này nhiều lần để ứng dụng vào triển khai dự án trong thực tế.

Mã nguồn của dự án các bạn tải ở đây:

https://www.mediafire.com/file/e40x1sw76s71jxl/LearnModelViewPart2.rar/file

Bài học sau Tui sẽ tiếp tục trình bày ứng dụng Model View vào QTableView để hiển thị và tương tác dữ liệu dạng bảng, một trong những kỹ thuật rất quan trọng và hữu ích. Các bạn chú ý theo dõi

Chúc các bạn thành công.

Bài 26: Kiến trúc Model View-PyQt6–Part 1

Trong lập trình xử lý tương tác dữ liệu có rất nhiều kỹ thuật, các bài học trước ta dùng các kỹ thuật thông thường và trực tiếp tương tác các đối tượng trên các Widget. Ngoài ra các kỹ sư đã phát triển kiến trúc liên quan tới Model View Controller, hay thường được gọi tắt là mô hình MVC để việc tương tác giao diện người dùng với dữ liệu cũng như các xử lý nghiệp vụ được dễ dàng hơn, dễ tái sử dụng hơn. PyQt6 cũng tích hợp mô hình MVC này và nó được gọi là Qt’s Model/Views Architecture. Chúng ta cùng điểm qua mô hình này:

Các bạn có thể hiểu nôm na mô hình Model View Controller nó hoạt động như sau:

  • Model: Nắm giữ cấu trúc dữ liệu trong ứng dụng mà nó đang làm việc. Ví dụ danh sách dữ liệu hướng đối tượng Product, Employee, Customer, Order, Provider… Khi người sử dụng thao tác trên View thì nó sẽ thông qua Controller để truy suất tới các Model tương ứng, các Model này sẽ hiển thị lên View thông qua Controller, có thể hiểu Controller là trung gian. Ví dụ trên Giao diện bạn muốn xem Danh sách PRODUCT, thì nó sẽ truyền tín hiện cho Controller PRODUCT, và controller này sẽ chọn đúng mô hình đối tượng PRODUCT, Model sẽ tạo ra danh sách Product và trả về cho Controller, sau đó Controller đẩy danh sách Product này lên View.
  • View: Là thành phần để hiển thị giao diện người dùng, nó cũng có nhiệm vụ nhận tương tác của người dùng và gọi Controller tương ứng. Nhiều View khác nhau có thể cùng hiển thị một loại dữ liệu, ví dụ bạn có 1 QTableWidget, 1 QListWidget, 1 QComboBox…các Widgets này có thể cùng hiển thị 1 danh sách model Product. Do đó ở đây bạn thấy tính tái sử dụng model rất cao.
  • Controller: Là thành phần trung gian, nó sẽ nhận các tương tác người dùng trên View, sau đó truyền đổi các tương tác này thành các lệnh để tương tác với Model để nhận các model tương ứng rồi nó sẽ hiển thị kết quả lên View hoặc tương tác với Model khác.

Với PyQt thì các kỹ sư có sự cải biên một xíu, đặc biệt là sự phân biệt giữa Model và View đôi khi nó khá mờ nhạt, khó nhận ra. Đồng thời View và Controller được hợp nhất lại với nhau để tao ra kiến trúc Model/ViewController hay nói tắt là Model View. PyQt chấp nhận các sự kiện từ người dùng thông qua hệ đều hành và ủy quyền những sự kiện này cho các widget để xử lý. Cái hay của Model View đó là giảm bớt sự cồng kềnh:

  • Model nắm giữ dữ liệu hoặc tham chiếu dữ liệu và trả về đối tượng đơn hoặc danh sách đối tượng. Khi model được thanh đổi dữ liệu nó sẽ ngay lập tức cập nhật lên giao diện
  • View sẽ yêu cầu dữ liệu từ Model và hiển thị lên các Widget tương ứng
  • Cơ chế Delegate giúp chương trình cập nhật dữ liệu mô hình tương ứng lên View một cách tự động.

Bài minh họa dưới đây, Chúng Ta sẽ làm một dự án hiển thị danh sách Employee đơn giản lên QListView sử dụng kiến trúc Model View của PyQt:

Ở hình trên Tui có minh họa là Chúng ta tạo một mô hình lớp Employee, và dùng kiến trúc Model View để hiển thị danh sách Employee lên QListView.

Ta thực hiện từng bước như sau:

Bước 1: Tạo dự án “LearnModelViewPart1” trong Pycharm có cấu trúc như hình dưới đây:

  • Employee.py là mô hình lớp hướng đối tượng định nghĩa cấu trúc dữ liệu cho Employee gồm id, name, age
  • EmployeeModel.py là lớp model view cho Employee, nó kế thừa từ “QAbstractListModel” lớp này sẽ lưu trữ danh sách dữ liệu mô hình Employee và hiển thị lên QListView
  • MainWindow.ui là màn hình giao diện được thiết kế bằng Qt Designer
  • MainWindow.py là lớp Generate Python của MainWindow.ui
  • MainWindowEx.py là lớp mã lệnh xử lý sự kiện người dùng cũng như lắp ráp dữ liệu trong mô hình Model View
  • MyApp.py là lớp thực thi chương trình

Bước 2: Tạo lớp Employee.py có mã lệnh như dưới đây:

class Employee:
    def __init__(self,id,name,age):
        self.id=id
        self.name=name
        self.age=age
    def __str__(self):
        return str(self.id)+"-"+self.name+"-"+str(self.age)

Lớp Employee có constructor nhận các đối số id, name, age và nó là các thuộc tính của Employee.

đồng thời hàm __str__ Tui thiết kế để nó tự động hiển thị lên giao diện như mong muốn, bạn có thể sửa lại.

Bước 3: Tạo lớp mô hình “EmployeeModel.py” và có mã lệnh như dưới đây:

from PyQt6.QtCore import QAbstractListModel, Qt

class EmployeeModel(QAbstractListModel):
    def __init__(self,employees=None):
        super().__init__()
        self.employees=employees
    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            emp = self.employees[index.row()]
            return str(emp)
    def rowCount(self, index):
        return len(self.employees)

Lớp EmployeeModel sẽ kế thừa từ lớp QAbstractListModel, chúng ta cần overridfe 3 hàm (bắt buộc):

  • hàm __init__ để khởi tạo các biến ban đầu cho model, ở đây Tui thiết kế cho nó nhận vào một danh sách Employee
  • hàm data có 2 đối số chính, đối số index (Có kiểu QIndex) là vị trí của dòng dữ liệu sẽ hiển thị lên QListView, do đó ra sẽ lấy index.row() để trả về integer để truy suất chính xác vị trí của dữ liệu trong danh sách employees. Ở đây chúng ta sử dụng Enum Qt.ItemDataRole để điều hướng cách thức hiển thị dữ liệu, Tui sẽ nó chi tiết ở bài học tiếp theo, trong bài này Chúng ta quan tâm tới DisplayRole có nghĩa là enum dùng để hiển thị dữ liệu. Hàm này dùng str(emp) để nó tự động gọi hàm __str__ mà ta thiết kế trong lớp Employee.py
  • hàm rowCount trả về số phần tử trong danh sách, bắt buộc chúng ta phải Override hàm này.

Bước 4: Thiết kế giao diện MainWindow.ui bằng Qt Designer

Ta chỉ cần kéo thả ListView trong nhóm Model-Based vào giao diện là xong.

Bước 5: Generate Python code cho MainWindow.ui

# Form implementation generated from reading ui file 'MainWindow.ui'
#
# Created by: PyQt6 UI code generator 6.5.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(351, 251)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.listView = QtWidgets.QListView(parent=self.centralwidget)
        self.listView.setGeometry(QtCore.QRect(10, 10, 311, 201))
        self.listView.setObjectName("listView")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 351, 22))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "Trần Duy Thanh - Model View"))

Bước 6: Tạo MainWindowEx để xử lý sự kiện, gán model cho widget

from Employee import Employee
from EmployeeModel import EmployeeModel
from MainWindow import Ui_MainWindow


class MainWindowEx(Ui_MainWindow):
    def __init__(self):
        pass
    def setupUi(self, MainWindow):
        super().setupUi(MainWindow)
        self.MainWindow=MainWindow
        employees=[]
        employees.append(Employee(1,"John",37))
        employees.append(Employee(2, "Peter", 24))
        employees.append(Employee(3, "Tom", 25))
        self.model=EmployeeModel(employees)
        self.listView.setModel(self.model)
    def show(self):
        self.MainWindow.show()

Ta thấy trong hàm override setupUi, Tui tạo ra 3 đối tượng employee và lưu trữ vào danh sách employees, sau đó nó được đưa vào EmployeeModel(employees)

cuối cùng ta gán model này cho QListVIew bằng hàm: setModel(model)

Bước 7: Viết MyApp.py để thực thi chương trình

from PyQt6.QtWidgets import QApplication, QMainWindow

from MainWindowEx import MainWindowEx

app=QApplication([])
myWindow=MainWindowEx()
myWindow.setupUi(QMainWindow())
myWindow.show()
app.exec()

Thực thi MyApp.py ta có kết quả như mong muốn.

Như vậy là tới đây Tui đã trình bày xong mô hình MVC, Qt Model/ViewController. Các bạn biết được lợi ích của mô hình và đặc biệt triển khai được một ví dụ minh họa cụ thể bằng Model View Architecture để hiển thị danh sách Employee lên QListView.

Sourcode đầy đủ của bài Model View này các bạn tải ở đây:

https://www.mediafire.com/file/7p75di7gwokzpa6/LearnModelViewPart1.rar/file

Bài học sau Tui tiếp tục trình bày chi tiết và chuyên sâu về model trong QListView, cung cấp thêm các chức năng: Thêm, Sửa, Xóa trên model. Các bạn chú ý theo dõi

Chúc các bạn thành công