• Курсы Академии Кодебай, стартующие в мае - июне, от команды The Codeby

    1. Цифровая криминалистика и реагирование на инциденты
    2. ОС Linux (DFIR) Старт: 16 мая
    3. Анализ фишинговых атак Старт: 16 мая Устройства для тестирования на проникновение Старт: 16 мая

    Скидки до 10%

    Полный список ближайших курсов ...

Статья Создаем приложение для хранения данных в базе sqlite с помощью Python и Qt. Часть 02

Данная статья является продолжением первой части (Создаем приложение для хранения данных в базе sqlite с помощью Python и Qt. Часть 01) в которой мы начали создавать приложение для хранения документов в формате HTML в базе данных sqlite с помощью Python и Qt Designer. И если в первой части мы рассмотрели код для взаимодействия с базой данных, то в этой части сосредоточимся на создании графического интерфейса и описания программной части для взаимодействия с его элементами.

000_02.png



Что потребуется?

Как устанавливать Qt Designer в операционных система мы рассмотрели в предыдущей статье. На всякий случай напомню как это делается, если вы первую часть не читали. Для установки Дизайнера в операционной системе Linux необходимо выполнить последовательно несколько команд в терминале:

Код:
sudo apt-get install python3-pyqt5
sudo apt-get install pyqt5-dev-tools
sudo apt-get install qttools5-dev-tools

Для установки Дизайнера в Windows выполняем следующую команду:

pip install pyqt5-tools --re

Либо скачать инсталлятор с сайта: . После того, как установиться Дизайнер, установим PyQt5 для взаимодействия с Qt с помощью Python.
Как в Windows, так и в Linux пишем команду:

pip install PyQt5

Ну и еще одна команда, которая установит библиотеку для отображения всплывающих сообщений в операционной системе Windows:

pip install win10toast


Создание графического интерфейса

Тут, выполнить действия с точностью до миллиметра, конечно же не получиться. Поэтому ограничусь только общим описанием дизайна. По итогу должно получиться что-то, подобное этому:

10.png

И дополнительно, так как мы будем использовать QTabWidget, изображение второй вкладки с редактором.

06.png

Создаем новую форму и размещаем на ней кнопки QToolButton:

01.png

Добавляем подписи к кнопкам и элементам:

02.png

А также размещаем остальные элементы.

03.png

Рекомендую для начала ознакомиться со всеми элементами, так как изображения представлены не совсем в том порядке, в котором они размещались на форме.
Двигаемся дальше и размещаем элементы на второй вкладке QTabWidget.

04.png

Для того, чтобы корректно назвать элементы, рекомендую ознакомиться для начала с кодом. Впрочем, размещать все на форме вам самостоятельно вовсе не обязательно. Достаточно скачать архив с уже созданной формой и результат ее конвертирования в код Python.
После того, как вы создали форму, ее нужно сохранить, желательно в папке с проектом, где уже должен лежать предыдущий файл doc.py из предыдущей статьи.

Конвертируем форму в код Python с помощью команды:

pyuic5 doc_b.ui -o doc_b.py

Как видите, ничего сложного, а созданную форму я назвал doc_b.ui. В принципе, это не особенно важно. Название может быть любое, главное, чтобы оно не совпадало с уже существующими модулями.
Теперь, когда все готово к написанию кода, нужно импортировать все необходимое в наш скрипт. Создадим файл main.py. В блоке импорта должно размещаться следующее:

Python:
import base64
import platform
import sqlite3
import sys
import tempfile
import webbrowser
from os import system
from pathlib import Path

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QFileDialog, QMessageBox

from doc import open_base, delete_from_base, encode_b64, \
    save_to_base, create_base, save_doc_from_base, decode_b64, vacuum_base, update_data, select_tag
from doc_b import *

Теперь создаем класс для приложения, инициализируем необходимые модули и определяем глобальные переменные. Это еще не полный класс и он будет продолжен немного позже.

Python:
class MyWin(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        QtWidgets.QWidget.__init__(self, parent)
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.base_name = None
        self.data = None

Также, создаем точку входа в наше приложение, в которой размещаем следующий код:

Python:
if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    myapp = MyWin()
    myapp.show()
    sys.exit(app.exec_())

Здесь мы создаем экземпляр класса, после чего отображаем его с помощью функции show(). С помощью метода exit мы завершаем запущенное приложение. Она позволяет корректно завершить приложение. Нижнее подчеркивание в exec_ используется потому, что exec уже используемое имя в Python и имя с подчеркиванием используется взамен.

Каркас нашего приложения готов. Теперь можно продолжить наполнять функцию инициализации и создавать функции для взаимодействия с кнопками приложения.
Дополним функцию __init__. Для начала, при инициализации определим, какие кнопки, поля ввода и другие элементы будут отключены, то есть, установим им свойство Enabled в значение True.

Python:
        self.ui.saveButton.setEnabled(False)
        self.ui.deleteButton.setEnabled(False)
        self.ui.eraseButton.setEnabled(False)
        self.ui.insertButton.setEnabled(False)
        self.ui.extractButton.setEnabled(False)
        self.ui.editButton.setEnabled(False)
        self.ui.tagButton.setEnabled(False)
        self.ui.clearButton.setEnabled(False)
        self.ui.vacuumButton.setEnabled(False)
        self.ui.tagEdit.setEnabled(False)
        self.ui.searchEdit.setEnabled(False)
        self.ui.tagBox.setEnabled(False)
        self.ui.tabTag.setEnabled(False)
        self.ui.filterButton.setEnabled(False)

Затем загрузим и разместим на форме в редакторе тегов картинки из папки pic. Содержимое данной папки также будет приложено в архиве.

Python:
        self.pix = QtGui.QPixmap(str(Path.cwd() / 'pic' / 'tag.png')).scaled(41, 40)
        self.ui.tagPic.setPixmap(self.pix)
        self.pix = QtGui.QPixmap(str(Path.cwd() / 'pic' / 'document.png')).scaled(41, 40)
        self.ui.docPic.setPixmap(self.pix)

Установим ширину столбцов в таблице и добавим в Label сообщение об открытой базе данных. В момент инициализации никакая из баз не открывается, поэтому пишем, что баз нет.

Python:
        self.ui.docTable.horizontalHeader().resizeSection(0, 740)
        self.ui.docTable.horizontalHeader().resizeSection(1, 185)
        self.ui.baseOpenName.setText('Open Base: None')

И следующий блок в котором определим функции, которые будут выполнятся при нажатии на кнопки и другие элементы интерфейса. Подробно его описывать большого смысла не имеет. Достаточно сказать, что каждый элемент через connect запускает на выполнение определенную функцию. И эти функции мы создадим здесь же, только ниже.

Python:
        self.ui.exitButton.clicked.connect(self.exit)
        self.ui.openButton.clicked.connect(self.open)
        self.ui.deleteButton.clicked.connect(self.delete)
        self.ui.eraseButton.clicked.connect(self.erase_base)
        self.ui.insertButton.clicked.connect(self.insert)
        self.ui.createButton.clicked.connect(self.create_base)
        self.ui.clearButton.clicked.connect(self.clear_text)
        self.ui.saveButton.clicked.connect(self.save)
        self.ui.editButton.clicked.connect(self.edit)
        self.ui.tagButton.clicked.connect(self.update_tag)
        self.ui.cancelButton.clicked.connect(self.cancel)
        self.ui.vacuumButton.clicked.connect(self.vacuum)
        self.ui.filterButton.clicked.connect(self.filter_tag)
        self.ui.extractButton.clicked.connect(self.extract_all)

        self.ui.searchEdit.textChanged.connect(self.search)
        self.ui.docTable.doubleClicked.connect(self.double_click_table)

С блоком инициализации закончили. Двигаемся дальше.


Вывод всплывающих сообщений

Создадим функцию класса notify(text). С ее помощью мы будем выводить всплывающие сообщения от различных функций. Данную функцию можно сделать статической, так как она не взаимодействует напрямую с элементами класса. Здесь же импортируем из win10toast ToastNotifier, с помощью которого будем выводить всплывающие сообщения в Windows. Импортируем его мы здесь, так как, если его импортировать в основном блоке функций, приложение вызовет исключение, что данный модуль недоступен. А установить в Linux его невозможно. Поэтому, скроем его вызов в функции от основного кода. Здесь же проверяем, в какой операционной системе запущено приложение и, в зависимости от этого, используем тот или иной способ.

Python:
    @staticmethod
    def notify(text):
        if platform.system() == "Linux":
            system(f'''notify-send "{text}"''')
        elif platform.system() == "Windows":
            from win10toast import ToastNotifier
            ToastNotifier().show_toast(f"Docu Base", text, duration=3, threaded=True)


Завершение работы приложения

С помощью данной функции мы будем обрабатывать нажатие на кнопку выхода. Ее тоже определим как статический метод, так как они вызывает у приложения метод quit и не взаимодействует с другими элементами класса.

Python:
    @staticmethod
    def exit(self):
        """
        Обработка нажатия на кнопку завершения приложения.
        Завершение работы приложения.
        """
        QtWidgets.QApplication.quit()


Уплотнение базы данных

Создадим функцию vacuum(self). С помощью данной функции мы будем обрабатывать событие при нажатии на кнопку сжатия базы данных. Здесь будем ориентироваться на значение переменной base_name, которую мы определили в блоке инициализации класса. Если ее значение None, значит база не открыта. А следовательно, выполнять действий не требуется. А вот если переменная содержит путь к базе данных, выполняем функцию vacuum_base(self.base_name), в которую передаем путь к базе данных и после завершения работы выводим всплывающее сообщение. Если же значение переменной base_name равно None, выводим сообщение, что открытых баз нет. И сжимать нечего.

Python:
    def vacuum(self):
        """
        Обработка нажатия кнопки сжатия базы данных.
        """
        if self.base_name is not None:
            vacuum_base(self.base_name)
            self.notify("[!] Base compacting completed.")
        else:
            self.notify("[!] There is no open database. Nothing to condense.")


Создание базы данных

С помощью функции create_base(self) мы будем обрабатывать нажатие на кнопку создания базы данных. Создадим эту функцию. Вызываем функцию, которую мы еще пока не написали, но которая делает кнопки неактивными. Это необходимо, если вы решите в процессе работы с другой базой данных создать новую. Так как созданная база будет пустой, значит и выполнение некоторых функций в ней попросту недоступно. Значит соответствующие кнопки нужно отключить. Вызываем диалог сохранения файла, куда передаем название окна, название базы по умолчанию, и формат данных, которые будет видеть при открытии пользователь. Проверяем возвращаемые значения. В переменную file возвращается путь к базе данных с ее именем, а в переменную check состояние — True или False.
К примеру, если мы отменили создание базы, в переменную вернется False. Проверяем содержание данной переменной. Если оно True, присваиваем переменной base_name значение пути к базе. Присваиваем текстовой метке, в которой выводиться название базы, полученное имя. Вызываем функцию создания базы данных из модуля doc.py, куда передаем путь к базе данных. Запускаем еще пока не созданную функцию для чтения данных из базы. И выводим сообщение для пользователя, что база создана.

Python:
    def create_base(self):
        """
        Обработка нажатия на кнопку создания базы данных.
        Создание базы данных. Запрос места и имени базы.
        Запуск функции по созданию.
        """
        self.button_disable()
        file, check = QFileDialog.getSaveFileName(None, "Create Base",
                                                  "new_docubase.db", "Docu Base (*.db)")
        if check:
            self.base_name = file
            self.ui.baseOpenName.setText(f'Open Base: {Path(file).name}')
            create_base(file)
            self.read_base()
            self.notify(f"[!] A database has been created: {Path(file).name}")


Обработка двойного клика левой кнопкой мыши по строке с названием документа в таблице

Создадим функцию double_click_table(self). С ее помощью мы будем обрабатывать двойной клик по названию документа в таблице. При двойном клике нам нужно будет открыть документ для просмотра в браузере по умолчанию, так как хранящиеся в ней документы имеют формат HTML. Получаем текущий индекс строки в таблице. Забираем по этому индексу из строки название документа. Вызываем функцию, которая делает выборку из базы по названию документа и возвращает кортеж с ними. В нее передаем путь к базе данных и название документа для выборки. Так как возвращается имя документа, теги и данные в base64, использовать будем только последние. Так как нам ни имя ни теги в данном случае не нужны. Мы будем использовать только данные в base64. Декодируем полученные данные с помощью метода decodebytes. И теперь, передаем все в модуль для создания временного файла с декодированным содержимым. Создаем файл во временной папке, откуда он впоследствии будет удален операционной системой. И передаем путь к созданному файлу в функцию webbrowser, которая и открывает созданный документ.

Python:
    def double_click_table(self):
        """
        Обработка двойного щелчка по названию документа в таблице.
        Создание временного файла с документом.
        Открытие документа из базы данных в браузере по-умолчанию.
        """
        row = self.ui.docTable.currentRow()
        _, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
        dec = base64.decodebytes(data)
        with tempfile.NamedTemporaryFile('wb', delete=False, suffix='.html') as f:
            f.write(dec)
        webbrowser.open(f'file://{f.name}')


Обновление тегов записи в базе данных

Создадим функцию для обработки кнопки редактора для обновления данных о тегах в базе — update_tag(self). Забираем имя документа из поля для имени в редакторе. Данное поле является неизменяемым, так что имя изменить нельзя. Редактируются только теги. Забираем теги, которые ввел пользователь. Передаем все в функцию для обновления данных в базе из модуля doc.py — update_data. Делаем все поля ввода и кнопки в редакторе неактивными, очищаем данные в полях ввода. Делаем неактивной вкладку с редактором. Тут, надо сказать, что я похоже слегка перестраховался )) Делаем активной вкладку с данными базы. Переключаемся на вкладку базы. Выполняем функцию для чтения данных и обновления таблицы. Так как теги будут изменены. Присваиваем переменной, в которой хранились данные в base64 значение None.

Python:
 def update_tag(self):
        """
        Обработка нажатия кнопки обновления тегов в БД.
        """
        name = self.ui.nameEdit.text()
        tag = self.ui.tagEdit.text().lower()
        update_data(self.base_name, name, tag, self.data)
        self.ui.tagEdit.setEnabled(False)
        self.ui.tagButton.setEnabled(False)
        self.ui.tagEdit.setText("")
        self.ui.nameEdit.setText("")
        self.ui.tabTag.setEnabled(False)
        self.ui.tabBase.setEnabled(True)
        self.ui.tabWidget.setCurrentIndex(0)
        self.read_base()
        self.data = None


Обработка нажатия кнопки отмены в редакторе тегов

Здесь же, в редакторе тегов присутствует кнопка отмены. Следовательно, нажатие на нее нужно обработать. Создадим функцию cancel(self). Здесь, все то же самое, что и в предыдущей функции, но в более усеченном виде. Очищаем значения полей для редактирования. Присваиваем переменной data значение None, делаем вкладку с редактором неактивной, активируем вкладку с базой и переключаемся на нее.

Python:
    def cancel(self):
        """
        Обработка нажатия кнопки отмены при редактировании тегов.
        """
        self.ui.tagEdit.setText("")
        self.ui.nameEdit.setText("")
        self.data = None
        self.ui.tabTag.setEnabled(False)
        self.ui.tabBase.setEnabled(True)
        self.ui.tabWidget.setCurrentIndex(0)


Запуск редактора тегов

Обработаем нажатие на кнопку запуска редактора тегов. Создадим функцию edit(self). Для того, чтобы передать в редактор значение с именем, нужно получить это имя, теги и байтовое содержимое из базы. Поэтому, первоначально требуется выделить с помощью мыши строку, в которой нужно редактировать теги. Если строка не выделена, произойдет ошибка. Поэтому, обработаем ее с помощью блока try — except, где будем выводить пользователю сообщение о том, что строка не выделена, редактировать нечего. Далее, в этом блоке, получаем нужные значения из базы с помощью функции save_doc_from_base, куда передаем путь к базе данных и название редактируемого документа. Присваиваем свойству текст полей для редактирования в редакторе полученные значения имени и теги, передаем в глобальную переменную байтовое содержимое. Делаем неактивной вкладку с базой, активируем вкладку с редактором и переключаемся на нее.

Python:
    def edit(self):
        """
        Обработка нажатия кнопки редактирования тегов.
        """
        try:
            row = self.ui.docTable.currentRow()
            name, tag, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            self.ui.nameEdit.setText(name)
            self.ui.tagEdit.setText(tag)
            self.ui.tagEdit.setEnabled(True)
            self.ui.tagButton.setEnabled(True)
            self.data = data
            self.ui.tabBase.setEnabled(False)
            self.ui.tabTag.setEnabled(True)
            self.ui.tabWidget.setCurrentIndex(1)
        except AttributeError:
            self.notify("No row selected for edit")
            pass


Очистка строки поиска

Данная функция служит для обработки нажатия на кнопку очистки строки поиска. Создадим функцию clear_text(self). И далее, по нажатии на кнопку присваиваем тексту в строке пустое значение.

Python:
    def clear_text(self):
        """
        Обработка нажатия на кнопку очистки текста в строке поиска.
        """
        self.ui.searchEdit.setText("")


Фильтрация документов в таблице по тегам

На форме приложения расположен QComboBox, в который мы помещаем теги документов, для последующей фильтрации по ним. Также, есть кнопка, по нажатию на которую и происходит фильтрация. Изначально я планировал сделать функцию, которая бы обрабатывала изменение содержимого бокса. Но, так как данные перечитываются из базы да и просто обновляется содержимое формы, слишком много реакций на изменения происходит в данном случае. Поэтому, добавил кнопку, нажатие на которую и нужно обработать. Выбираем в комбо-боксе тег, нажимаем на кнопку. Создадим функцию filter_tag(self). Получаем значение текущего текста из комбо-бокса. Проверяем, не является ли данное значение «all». То есть, по сути, отключение фильтра. Если да, запускаем функцию, с помощью которой читаем все данные из базы. Если нет, двигаемся дальше. Очищаем виджет таблицы. В цикле итерируемся по полученным с помощью функции select_tag значениям и добавляем их в таблицу на текущую позицию.

Python:
    def filter_tag(self):
        tag = self.ui.tagBox.currentText()
        if tag == "all":
            self.read_base()
        else:
            self.ui.docTable.setRowCount(0)
            for item in select_tag(self.base_name, tag):
                row_position = self.ui.docTable.rowCount()
                self.ui.docTable.insertRow(row_position)
                self.ui.docTable.setItem(row_position, 0, QtWidgets.QTableWidgetItem(item[0]))
                self.ui.docTable.setItem(row_position, 1, QtWidgets.QTableWidgetItem(item[1]))


Вспомогательная функция для чтения данных из базы

Данная функция не обрабатывает нажатий на кнопки. Основное ее предназначение — прочитать данные из базы, добавить записи в таблицу и теги в комбо-бокс. Создадим функцию read_base(self). Создадим множество, в которое будем помещать полученные теги. Это необходимо для избежания дублирования, так как теги в базе могут и будут встречаться одинаковые. Очищаем виджет таблицы. Очищаем значения в комбо-боксе. В цикле итерируемся по значениям полученным с помощью функции open_base. В данную функцию передаем имя базы данных. Возвращает она названия документов и теги. Заполняем полученными значениями. Здесь же обрабатываем теги. Так как строка с тегами может содержать больше одного значения, их нужно разделить и поместить в множество для последующей обработки. Проверяем длину списка при разделении по запятой. Если она больше 1, значит в ней содержится несколько тегов. В цикле забираем каждый из них и помещаем в множество. Если же в строке только один элемент, помещаем в множество его. Добавляем в комбо-бокс тег «all», итерируемся по множеству с тегами и добавляем их в него также. Проверяем длину виджета таблицы, содержаться ли там записи. Если она больше 0, делаем активными все кнопки для редактирования и прочей их обработки. Если же меньше 0, делаем кнопки обработки неактивными, оставляем активными только кнопку выхода, открытия базы и добавления записей в базу. И последней строкой делаем таблицу не редактируемой установкой соответствующего триггера.

Python:
   def read_base(self):
        """
        Чтение данных из базы. Обработка содержимого таблицы.
        Добавление активности кнопкам.
        Вспомогательная функция.
        """
        tag_set = set()
        self.ui.docTable.setRowCount(0)
        self.ui.tagBox.clear()
        for name in open_base(self.base_name):
            row_position = self.ui.docTable.rowCount()
            self.ui.docTable.insertRow(row_position)
            self.ui.docTable.setItem(row_position, 0, QtWidgets.QTableWidgetItem(name[0]))
            self.ui.docTable.setItem(row_position, 1, QtWidgets.QTableWidgetItem(name[1]))
            if len(name[1].split(",")) > 1:
                for t in name[1].split(","):
                    tag_set.add(t.strip())
                continue
            tag_set.add(name[1])
        self.ui.tagBox.addItem("all")
        for tag in sorted(tag_set):
            self.ui.tagBox.addItem(tag)
        if self.ui.docTable.rowCount() > 0:
            self.button_enable()
        else:
            self.button_disable()
            self.ui.insertButton.setEnabled(True)
        self.ui.docTable.setEditTriggers(QtWidgets.QtableWidget.EditTrigger.NoEditTriggers)


Активация кнопок

Создадим еще одну вспомогательную функцию. Все, что она будет делать, это активировать кнопки указанные в ней. Назовем ее button_enable(self). Описывать подробно не имеет смысла, так как действия в ней однотипные. В данном случае у элементов формы свойство Enable устанавливается в значение True.

Python:
    def button_enable(self):
        """
        Вспомогательная функция.
        Делает активными указанные кнопки.
        """
        self.ui.saveButton.setEnabled(True)
        self.ui.deleteButton.setEnabled(True)
        self.ui.eraseButton.setEnabled(True)
        self.ui.insertButton.setEnabled(True)
        self.ui.extractButton.setEnabled(True)
        self.ui.editButton.setEnabled(True)
        self.ui.vacuumButton.setEnabled(True)
        self.ui.searchEdit.setEnabled(True)
        self.ui.clearButton.setEnabled(True)
        self.ui.tagBox.setEnabled(True)
        self.ui.filterButton.setEnabled(True)


Деактивация кнопок

Создадим вспомогательную функцию button_disable(self). В целом, она аналогична предыдущей, только значение Enable устанавливает в значение False, чем делает элементы формы неактивными.

Python:
    def button_disable(self):
        """
        Вспомогательная функция.
        Делает не активными указанные кнопки.
        """
        self.ui.saveButton.setEnabled(False)
        self.ui.deleteButton.setEnabled(False)
        self.ui.eraseButton.setEnabled(False)
        self.ui.insertButton.setEnabled(False)
        self.ui.extractButton.setEnabled(False)
        self.ui.editButton.setEnabled(False)
        self.ui.searchEdit.setEnabled(False)
        self.ui.clearButton.setEnabled(False)
        self.ui.tagBox.setEnabled(False)
        self.ui.filterButton.setEnabled(False)


Открытие базы данных

Создадим обработчик нажатия кнопки открытия базы данных. Я назвал его open(self). Событий здесь происходит не особо много, благодаря вспомогательным функциям. Для начала, делаем неактивными нужные кнопки. Затем вызываем диалог выбора файла с базой данных. Проверяем значение check. Если оно True, присваиваем переменной base_name путь к базе данных. Делаем активной кнопку уплотнения базы. Присваиваем лейблу, в котором отображается имя открытой базы название базы. И выполняем функцию чтения данных из БД.

Python:
    def open(self):
        """
        Обработка нажатия на кнопку открытия базы данных.
        """
        self.button_disable()
        file, check = QFileDialog.getOpenFileName(None, "Open Docu Base", "", "Docu Base (*.db)")
        if check:
            self.base_name = file
            self.ui.vacuumButton.setEnabled(True)
            self.ui.baseOpenName.setText(f'Open Base: {Path(file).name}')
            self.read_base()


Сохранение документа на диск

Создадим функцию save(self). С ее помощью мы будем обрабатывать сохранение выделенного в таблице документа на диск. То есть, обрабатывать событие при нажатии соответствующей кнопки. Обернем весь код в блок try — except, в котором будем обрабатывать возникающее исключение, если не выделена строка в таблице. Выводим сообщение об этом пользователю. Если же строка выделена, забираем ее индекс, получаем текст с названием документа, передаем в функцию save_doc_from_base вместе с путем к базе данных. Значение тегов в данном случае нам не нужно, поэтому вместо него ставим заглушку. Запрашиваем у пользователя путь и название сохраняемого документа с помощью диалога getSaveFileName, куда передаем название для окна, название документа по умолчанию, так, как оно есть в базе, и формат данных. Проверяем check. Если True, передаем путь для сохранения файла и закодированные данные в функцию decode_b64. В ней и происходит декодирование и сохранение документа.

Python:
    def save(self):
        """
        Обработка нажатия на кнопку сохранения выделенного документа из базы.
        """
        try:
            row = self.ui.docTable.currentRow()
            name, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            file, check = QFileDialog.getSaveFileName(None, "Save From Base", f"{name}.html", "HTML (*.html)")
            if check:
                decode_b64(file, data)
        except AttributeError:
            self.notify("No row selected for save")
            pass


Сохранение всех документов из базы

Создадим обработчик нажатия на кнопку сохранения всех документов — extract_all(self). Проверяем длину виджета таблицы. Если она больше 0, то есть, таблица не является пустой, запускаем диалог получения пути к папке для сохранения. Создаем список feeds. В данном списке будут храниться списки с путем для сохранения документа и его закодированное значение. Далее, итерируемся по списку со списками, забираем оттуда значения и отправляем в функцию декодирования и сохранения. После выполнения операции выводим сообщение для пользователя о ее завершении.

Python:
    def extract_all(self):
        """
        Обработка нажатия на кнопку сохранения всех документов из базы.
        """
        if self.ui.docTable.rowCount() > 0:
            directory = QtWidgets.QFileDialog.getExistingDirectory(None, "Selecting a folder to save in", ".")
            feeds = []
            for i in range(self.ui.docTable.rowCount()):
                _, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(i, 0).text())
                file = Path(directory) / f'{self.ui.docTable.item(i, 0).text()}.html'
                feeds.append([file, data])

            for feed in feeds:
                self.ui.baseOpenName.setText(f'Extract: {Path(feed[0]).name}')
                decode_b64(feed[0], feed[1])
            self.ui.baseOpenName.setText(f'Open Base: {Path(self.base_name).name}')
            self.notify(f"[!] All data is saved in a folder: {directory}")


Создание диалогового окна выбора

Создадим вспомогательную функцию def msg_box(name). Определим ее статической. Создадим объект QmessageBox. Передадим в него заголовок создаваемого окна, текст, который будет выводится для пользователя и две кнопки — Да/Нет. После чего возвращаем из функции значение, которое возвращается при нажатии кнопки. А возвращается ее числовой код, который мы будем обрабатывать далее.

Python:
    @staticmethod
    def msg_box(name):
        """
        Создание диалогового окна для проверки существования записей в базе.
        :param name: Название статьи.
        :return: Код нажатой кнопки.
        """
        msg = QMessageBox()
        msg.setWindowTitle("Database warning")
        msg.setText(f'The "{name}" file exists in the database!\nAdd a file to the database?')
        msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
        return msg.exec_()


Добавление документов в базу данных

Создадим обработчик события при нажатии на кнопку добавления документов в базу данных. Назовем функцию insert(self). Здесь будем подключаться к базе напрямую, так как нам нужно сделать выборку из базы, на предмет присутствия данного документа в ней. Создаем запрос. Открываем диалоговое окно выбора файлов. Затем создаем список, куда будем добавлять полученный из функции кодирования в base64 кортеж с название документа, тегом и кодированными байтовыми данными. Итерируемся по списку полученных путей к файлам. Делаем выборку из базы. Если вернулось значение None, значит документа в базе нет и можно передать его для кодирования. Если файла в базе есть, создаем диалоговое окно, в котором сообщаем, что добавляемый файл существует. Если пользователь выбирает нет, продолжаем итерацию по списку с путями. Если да, кодируем и добавляем возвращенный кортеж в список. После обработки всех путей к файлам закрываем соединение с базой данных. Ну, а далее итерируемся по заполненному списку. Запускаем функцию сохранения документов в базе, куда передаем список с кортежами. Очищаем виджет таблицы, читаем данные из базы и заполняем таблицу с помощью соответствующей функции. И выводим сообщения для пользователя об окончании операции добавления.

Python:
    def insert(self):
        """
        Обработка нажатия на кнопку добавления документов в базу данных.
        """
        conn = sqlite3.connect(self.base_name)
        cur = conn.cursor()
        sel = """SELECT name FROM documents WHERE name = ?"""
        files, check = QFileDialog.getOpenFileNames(None, "Open HTML",
                                                    "", "HTML (*.html)")
        data_list = []
        if check:
            for file in files:
                if cur.execute(sel, (Path(file).name.replace(".html", ""),)).fetchone() is not None:
                    x = self.msg_box(Path(file).name.replace(".html", ""))
                    if x == 65536:
                        continue
                    elif x == 16384:
                        self.ui.baseOpenName.setText(f'Insert: {Path(file).name}')
                        data_list.append(encode_b64(file))
                        continue
                self.ui.baseOpenName.setText(f'Insert: {Path(file).name}')
                data_list.append(encode_b64(file))
        cur.close()
        conn.close()

        if data_list:
            save_to_base(self.base_name, data_list)
            self.ui.docTable.setRowCount(0)
            self.read_base()
            self.ui.baseOpenName.setText(f'Open Base: {Path(self.base_name).name}')
            self.notify("[!] All files are added to the database")


Поиск документов в виджете таблицы

Создадим функцию search(self, s), которая будет обрабатывать ввод в строке поиска и подсвечивание найденных строк в таблице. Устанавливаем значение текущей строки в None. Проверяем, есть ли введенный текст. Если нет, выходим из функции. Если есть, выполняем поиск с помощью функции findItems, куда передаем вводимые данные. Затем итерируемся по найденным значениям и подсвечиваем строки выделением.

Python:
    def search(self, s):
        """
        Поиск документов в таблице.
        :param s: Вводимый текст в строке поиска.
        """
        self.ui.docTable.setCurrentItem(None)
        if not s:
            return
        matching_items = self.ui.docTable.findItems(s, Qt.MatchContains)
        if matching_items:
            for item in matching_items:
                item.setSelected(True)


Полная очистка базы данных

Создадим функцию для обработки нажатия на кнопку полной очистки базы данных — erase_base(self). В цикле итерируемся в диапазоне равном количеству записей в таблице. Забираем из таблицы по текущему значению название документа, передаем в функцию удаления записи путь к базе данных и название документа. После окончания итерации очищаем комбо-бокс с тегами. Запускаем функцию чтения данных из базы. Делаем активной кнопку уплотнения базы и выводим сообщение для пользователя.

Python:
    def erase_base(self):
        """
        Обработка нажатия на кнопку удаления всех документов из базы.
        """
        for i in range(self.ui.docTable.rowCount()):
            delete_from_base(self.base_name, self.ui.docTable.item(i, 0).text())
        self.ui.tagBox.clear()
        self.read_base()
        self.ui.vacuumButton.setEnabled(True)
        self.notify("All documents have been deleted from the database")


Удаление выделенной записи из базы

Создадим последнюю в данном скрипте функцию обработки нажатия на кнопку удаления выделенного в таблице документа из базы данных. Назовем ее delete(self). Добавить обработку исключения try — except, так как данная функция обрабатывает удаление выделенной записи из базы. Если пользователь не выделит нужный документ, а нажмет на кнопку, ему выведется соответствующее всплывающее сообщение. Считываем индекс строки таблицы. Передаем путь к базе данных и полученное название документа из таблицы в функцию удаления записи из базы. Запускаем функцию чтения данных из базы. Делаем активной кнопку уплотнения базы.

Python:
    def delete(self):
        """
        Обработка нажатия на кнопку удаления выделенного документа из базы.
        """
        try:
            row = self.ui.docTable.currentRow()
            delete_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            self.ui.docTable.removeRow(row)
            self.read_base()
            self.ui.vacuumButton.setEnabled(True)
        except AttributeError:
            self.notify("No row selected for deletion")
            pass


Такой вот код у нас получился. Ниже, в спойлере, приведен полный код файла main.py.

Python:
import base64
import platform
import sqlite3
import sys
import tempfile
import webbrowser
from os import system
from pathlib import Path

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QFileDialog, QMessageBox

from doc import open_base, delete_from_base, encode_b64, \
    save_to_base, create_base, save_doc_from_base, decode_b64, vacuum_base, update_data, select_tag
from doc_b import *


class MyWin(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        QtWidgets.QWidget.__init__(self, parent)
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.base_name = None
        self.data = None

        self.ui.saveButton.setEnabled(False)
        self.ui.deleteButton.setEnabled(False)
        self.ui.eraseButton.setEnabled(False)
        self.ui.insertButton.setEnabled(False)
        self.ui.extractButton.setEnabled(False)
        self.ui.editButton.setEnabled(False)
        self.ui.tagButton.setEnabled(False)
        self.ui.clearButton.setEnabled(False)
        self.ui.vacuumButton.setEnabled(False)
        self.ui.tagEdit.setEnabled(False)
        self.ui.searchEdit.setEnabled(False)
        self.ui.tagBox.setEnabled(False)
        self.ui.tabTag.setEnabled(False)
        self.ui.filterButton.setEnabled(False)

        self.pix = QtGui.QPixmap(str(Path.cwd() / 'pic' / 'tag.png')).scaled(41, 40)
        self.ui.tagPic.setPixmap(self.pix)
        self.pix = QtGui.QPixmap(str(Path.cwd() / 'pic' / 'document.png')).scaled(41, 40)
        self.ui.docPic.setPixmap(self.pix)

        self.ui.docTable.horizontalHeader().resizeSection(0, 740)
        self.ui.docTable.horizontalHeader().resizeSection(1, 185)
        self.ui.baseOpenName.setText('Open Base: None')

        self.ui.exitButton.clicked.connect(self.exit)
        self.ui.openButton.clicked.connect(self.open)
        self.ui.deleteButton.clicked.connect(self.delete)
        self.ui.eraseButton.clicked.connect(self.erase_base)
        self.ui.insertButton.clicked.connect(self.insert)
        self.ui.createButton.clicked.connect(self.create_base)
        self.ui.clearButton.clicked.connect(self.clear_text)
        self.ui.saveButton.clicked.connect(self.save)
        self.ui.editButton.clicked.connect(self.edit)
        self.ui.tagButton.clicked.connect(self.update_tag)
        self.ui.cancelButton.clicked.connect(self.cancel)
        self.ui.vacuumButton.clicked.connect(self.vacuum)
        self.ui.filterButton.clicked.connect(self.filter_tag)
        self.ui.extractButton.clicked.connect(self.extract_all)

        self.ui.searchEdit.textChanged.connect(self.search)
        self.ui.docTable.doubleClicked.connect(self.double_click_table)

    @staticmethod
    def notify(text):
        if platform.system() == "Linux":
            system(f'''notify-send "{text}"''')
        elif platform.system() == "Windows":
            from win10toast import ToastNotifier
            ToastNotifier().show_toast(f"Docu Base", text, duration=3, threaded=True)

    @staticmethod
    def exit(self):
        """
        Обработка нажатия на кнопку завершения приложения.
        Завершение работы приложения.
        """
        QtWidgets.QApplication.quit()

    def vacuum(self):
        """
        Обработка нажатия кнопки сжатия базы данных.
        """
        if self.base_name is not None:
            vacuum_base(self.base_name)
            self.notify("[!] Base compacting completed.")
        else:
            self.notify("[!] There is no open database. Nothing to condense.")

    def create_base(self):
        """
        Обработка нажатия на кнопку создания базы данных.
        Создание базы данных. Запрос места и имени базы.
        Запуск функции по созданию.
        """
        self.button_disable()
        file, check = QFileDialog.getSaveFileName(None, "Create Base",
                                                  "new_docubase.db", "Docu Base (*.db)")
        if check:
            self.base_name = file
            self.ui.baseOpenName.setText(f'Open Base: {Path(file).name}')
            create_base(file)
            self.read_base()
            self.notify(f"[!] A database has been created: {Path(file).name}")

    def double_click_table(self):
        """
        Обработка двойного щелчка по названию документа в таблице.
        Создание временного файла с документом.
        Открытие документа из базы данных в браузере по-умолчанию.
        """
        row = self.ui.docTable.currentRow()
        _, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
        dec = base64.decodebytes(data)
        with tempfile.NamedTemporaryFile('wb', delete=False, suffix='.html') as f:
            f.write(dec)
        webbrowser.open(f'file://{f.name}')

    def update_tag(self):
        """
        Обработка нажатия кнопки обновления тегов в БД.
        """
        name = self.ui.nameEdit.text()
        tag = self.ui.tagEdit.text().lower()
        update_data(self.base_name, name, tag, self.data)
        self.ui.tagEdit.setEnabled(False)
        self.ui.tagButton.setEnabled(False)
        self.ui.tagEdit.setText("")
        self.ui.nameEdit.setText("")
        self.ui.tabTag.setEnabled(False)
        self.ui.tabBase.setEnabled(True)
        self.ui.tabWidget.setCurrentIndex(0)
        self.read_base()
        self.data = None

    def cancel(self):
        """
        Обработка нажатия кнопки отмены при редактировании тегов.
        """
        self.ui.tagEdit.setText("")
        self.ui.nameEdit.setText("")
        self.data = None
        self.ui.tabTag.setEnabled(False)
        self.ui.tabBase.setEnabled(True)
        self.ui.tabWidget.setCurrentIndex(0)

    def edit(self):
        """
        Обработка нажатия кнопки редактирования тегов.
        """
        try:
            row = self.ui.docTable.currentRow()
            name, tag, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            self.ui.nameEdit.setText(name)
            self.ui.tagEdit.setText(tag)
            self.ui.tagEdit.setEnabled(True)
            self.ui.tagButton.setEnabled(True)
            self.data = data
            self.ui.tabBase.setEnabled(False)
            self.ui.tabTag.setEnabled(True)
            self.ui.tabWidget.setCurrentIndex(1)
        except AttributeError:
            self.notify("No row selected for edit")
            pass

    def clear_text(self):
        """
        Обработка нажатия на кнопку очистки текста в строке поиска.
        """
        self.ui.searchEdit.setText("")

    def filter_tag(self):
        tag = self.ui.tagBox.currentText()
        if tag == "all":
            self.read_base()
        else:
            self.ui.docTable.setRowCount(0)
            for item in select_tag(self.base_name, tag):
                row_position = self.ui.docTable.rowCount()
                self.ui.docTable.insertRow(row_position)
                self.ui.docTable.setItem(row_position, 0, QtWidgets.QTableWidgetItem(item[0]))
                self.ui.docTable.setItem(row_position, 1, QtWidgets.QTableWidgetItem(item[1]))

    def read_base(self):
        """
        Чтение данных из базы. Обработка содержимого таблицы.
        Добавление активности кнопкам.
        Вспомогательная функция.
        """
        tag_set = set()
        self.ui.docTable.setRowCount(0)
        self.ui.tagBox.clear()
        for name in open_base(self.base_name):
            row_position = self.ui.docTable.rowCount()
            self.ui.docTable.insertRow(row_position)
            self.ui.docTable.setItem(row_position, 0, QtWidgets.QTableWidgetItem(name[0]))
            self.ui.docTable.setItem(row_position, 1, QtWidgets.QTableWidgetItem(name[1]))
            if len(name[1].split(",")) > 1:
                for t in name[1].split(","):
                    tag_set.add(t.strip())
                continue
            tag_set.add(name[1])
        self.ui.tagBox.addItem("all")
        for tag in sorted(tag_set):
            self.ui.tagBox.addItem(tag)
        if self.ui.docTable.rowCount() > 0:
            self.button_enable()
        else:
            self.button_disable()
            self.ui.insertButton.setEnabled(True)
        self.ui.docTable.setEditTriggers(QtWidgets.QTableWidget.EditTrigger.NoEditTriggers)

    def button_enable(self):
        """
        Вспомогательная функция.
        Делает активными указанные кнопки.
        """
        self.ui.saveButton.setEnabled(True)
        self.ui.deleteButton.setEnabled(True)
        self.ui.eraseButton.setEnabled(True)
        self.ui.insertButton.setEnabled(True)
        self.ui.extractButton.setEnabled(True)
        self.ui.editButton.setEnabled(True)
        self.ui.vacuumButton.setEnabled(True)
        self.ui.searchEdit.setEnabled(True)
        self.ui.clearButton.setEnabled(True)
        self.ui.tagBox.setEnabled(True)
        self.ui.filterButton.setEnabled(True)

    def button_disable(self):
        """
        Вспомогательная функция.
        Делает не активными указанные кнопки.
        """
        self.ui.saveButton.setEnabled(False)
        self.ui.deleteButton.setEnabled(False)
        self.ui.eraseButton.setEnabled(False)
        self.ui.insertButton.setEnabled(False)
        self.ui.extractButton.setEnabled(False)
        self.ui.editButton.setEnabled(False)
        self.ui.searchEdit.setEnabled(False)
        self.ui.clearButton.setEnabled(False)
        self.ui.tagBox.setEnabled(False)
        self.ui.filterButton.setEnabled(False)

    def open(self):
        """
        Обработка нажатия на кнопку открытия базы данных.
        """
        self.button_disable()
        file, check = QFileDialog.getOpenFileName(None, "Open Docu Base", "", "Docu Base (*.db)")
        if check:
            self.base_name = file
            self.ui.vacuumButton.setEnabled(True)
            self.ui.baseOpenName.setText(f'Open Base: {Path(file).name}')
            self.read_base()

    def save(self):
        """
        Обработка нажатия на кнопку сохранения выделенного документа из базы.
        """
        try:
            row = self.ui.docTable.currentRow()
            name, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            file, check = QFileDialog.getSaveFileName(None, "Save From Base", f"{name}.html", "HTML (*.html)")
            if check:
                decode_b64(file, data)
        except AttributeError:
            self.notify("No row selected for save")
            pass

    def extract_all(self):
        """
        Обработка нажатия на кнопку сохранения всех документов из базы.
        """
        if self.ui.docTable.rowCount() > 0:
            directory = QtWidgets.QFileDialog.getExistingDirectory(None, "Selecting a folder to save in", ".")
            feeds = []
            for i in range(self.ui.docTable.rowCount()):
                _, _, data = save_doc_from_base(self.base_name, self.ui.docTable.item(i, 0).text())
                file = Path(directory) / f'{self.ui.docTable.item(i, 0).text()}.html'
                feeds.append([file, data])

            for feed in feeds:
                self.ui.baseOpenName.setText(f'Extract: {Path(feed[0]).name}')
                decode_b64(feed[0], feed[1])
            self.ui.baseOpenName.setText(f'Open Base: {Path(self.base_name).name}')
            self.notify(f"[!] All data is saved in a folder: {directory}")

    @staticmethod
    def msg_box(name):
        """
        Создание диалогового окна для проверки существования записей в базе.
        :param name: Название статьи.
        :return: Код нажатой кнопки.
        """
        msg = QMessageBox()
        msg.setWindowTitle("Database warning")
        msg.setText(f'The "{name}" file exists in the database!\nAdd a file to the database?')
        msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
        return msg.exec_()

    def insert(self):
        """
        Обработка нажатия на кнопку добавления документов в базу данных.
        """
        conn = sqlite3.connect(self.base_name)
        cur = conn.cursor()
        sel = """SELECT name FROM documents WHERE name = ?"""
        files, check = QFileDialog.getOpenFileNames(None, "Open HTML",
                                                    "", "HTML (*.html)")
        data_list = []
        if check:
            for file in files:
                if cur.execute(sel, (Path(file).name.replace(".html", ""),)).fetchone() is not None:
                    x = self.msg_box(Path(file).name.replace(".html", ""))
                    if x == 65536:
                        continue
                    elif x == 16384:
                        self.ui.baseOpenName.setText(f'Insert: {Path(file).name}')
                        data_list.append(encode_b64(file))
                        continue
                self.ui.baseOpenName.setText(f'Insert: {Path(file).name}')
                data_list.append(encode_b64(file))
        cur.close()
        conn.close()

        if data_list:
            save_to_base(self.base_name, data_list)
            self.ui.docTable.setRowCount(0)
            self.read_base()
            self.ui.baseOpenName.setText(f'Open Base: {Path(self.base_name).name}')
            self.notify("[!] All files are added to the database")

    def search(self, s):
        """
        Поиск документов в таблице.
        :param s: Вводимый текст в строке поиска.
        """
        self.ui.docTable.setCurrentItem(None)
        if not s:
            return
        matching_items = self.ui.docTable.findItems(s, Qt.MatchContains)
        if matching_items:
            for item in matching_items:
                item.setSelected(True)

    def erase_base(self):
        """
        Обработка нажатия на кнопку удаления всех документов из базы.
        """
        for i in range(self.ui.docTable.rowCount()):
            delete_from_base(self.base_name, self.ui.docTable.item(i, 0).text())
        self.ui.tagBox.clear()
        self.read_base()
        self.ui.vacuumButton.setEnabled(True)
        self.notify("All documents have been deleted from the database")

    def delete(self):
        """
        Обработка нажатия на кнопку удаления выделенного документа из базы.
        """
        try:
            row = self.ui.docTable.currentRow()
            delete_from_base(self.base_name, self.ui.docTable.item(row, 0).text())
            self.ui.docTable.removeRow(row)
            self.read_base()
            self.ui.vacuumButton.setEnabled(True)
        except AttributeError:
            self.notify("No row selected for deletion")
            pass


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    myapp = MyWin()
    myapp.show()
    sys.exit(app.exec_())

Ну и в завершении статьи и создании приложения, небольшое видео его работы с YouTube.


Код и файлы ресурсов будут добавлены во вложении.

А на этом, пожалуй, все.

Спасибо за внимание. Надеюсь, данная информация была вам полезна
 

Вложения

  • docu_base.zip
    376,8 КБ · Просмотры: 243
  • Нравится
Реакции: InternetMC
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!