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

Leave a Reply