# PyQt6 Scaffold Обёртка для PyQt6, делающая работу удобнее [English documentation](../README.md) ## Лицензия Этот проект лицензирован под лицензией LGPLv3. Для подробностей ознакомьтесь с файлом LICENSE. # Описание Эта библиотека предоставляет набор готовых паттернов и инструментов, которые можно сразу использовать для разработки и избежать ошибок в сложных местах (вроде базы данных или навигации между окнами) Библиотека делится на 2 больших модуля: - Core - Contrib ## Установка Базовая установка (только ядро): ```bash pip install pyqt6-scaffold ``` С поддержкой MySQL: ```bash pip install pyqt6-scaffold[mysql] ``` С поддержкой PostgreSQL: ```bash pip install pyqt6-scaffold[postgres] ``` Все драйверы сразу: ```bash pip install pyqt6-scaffold[all] ``` ## Быстрый старт ### 1. Настройте переменные окружения Создайте файл `start.sh` (Linux/macOS) или `start.cmd` (Windows): ```bash # start.sh export MYSQL_HOST=localhost export MYSQL_PORT=3306 export MYSQL_USER=root export MYSQL_DATABASE=mydb export MYSQL_PASSWORD=secret python main.py ``` ```cmd :: start.cmd set MYSQL_HOST=localhost set MYSQL_PORT=3306 set MYSQL_USER=root set MYSQL_DATABASE=mydb set MYSQL_PASSWORD=secret python main.py ``` ### 2. Реализуйте базу данных ```python from pyqt6_scaffold import BaseUser from pyqt6_scaffold.contrib import MysqlDatabase class AppDatabase(MysqlDatabase): def auth(self, login: str, password: str): with self.execute( f"SELECT id, name FROM users WHERE login = {self.placeholder} AND password = {self.placeholder}", (login, password) ) as cursor: row = cursor.fetchone() if not row: return None return BaseUser(id=row[0], name=row[1], role=None) ``` ### 3. Создайте окна ```python from pyqt6_scaffold import BaseWindow from PyQt6.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget class LoginWindow(BaseWindow): def _define_widgets(self): self._login_input = QLineEdit() self._password_input = QLineEdit() self._pas3. Create Windowssword_input.setEchoMode(QLineEdit.EchoMode.Password) self._submit_btn = QPushButton("Войти") def _tune_layouts(self): layout = QVBoxLayout() layout.addWidget(QLabel("Логин:")) layout.addWidget(self._login_input) layout.addWidget(QLabel("Пароль:")) layout.addWidget(self._password_input) layout.addWidget(self._submit_btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def _connect_slots(self): self._submit_btn.clicked.connect(self._on_submit) def _apply_windows_settings(self): self.setWindowTitle("Вход") def _on_submit(self): user = self._db.auth( self._login_input.text(), self._password_input.text() ) if user is None: return from pyqt6_scaffold import NavigateRequest, NavigationContext self._composer.navigate_request.emit( NavigateRequest( target="main", context=NavigationContext(data={"user": user}) ) ) class MainWindow(BaseWindow): def _apply_windows_settings(self): self.setWindowTitle("Главное окно") ``` ### 4. Соберите приложение в main.py ```python import sys from PyQt6.QtWidgets import QApplication from pyqt6_scaffold import Composer def main(): app = QApplication(sys.argv) db = AppDatabase() db.connect() composer = Composer(app=app, db=db) composer.register("login", LoginWindow) composer.register("main", MainWindow) sys.exit(composer.run(start="login")) if __name__ == "__main__": main() ``` ## Модуль Сore Модуль, предоставляющий основные инструменты для работы, как правило это абстрактные классы, реализующие удобный шаблон для разработки. Например, класс BaseWindow реализующий минимально приемлемый (по мнению автора) шаблон для окна приложения: ```python from PyQt6.QtWidgets import QMainWindow class BaseWindow(QMainWindow): def __init__(self, composer, db): super().__init__() self._db = db self._composer = composer self._define_widgets() self._tune_layouts() self._connect_slots() self._apply_windows_settings() def _define_widgets(self): pass def _tune_layouts(self): pass def _connect_slots(self): pass def _apply_windows_settings(self): pass ``` Главные составляюшие модуля Core это классы навигации и взаимодействия с базой данных. ### База Данных За управление базой данных в библиотеке отвечает класс ```AbstractDatabase```. Этот класс выглядит примерно так: ![AbstractDatabase](https://www.plantuml.com/plantuml/svg/NP1HIiKm44N_iue1Vz9QDr0GdY2kmFzXcWuQJ9EQcMX5tBlfwwkLbw-RcplSapc9KjOo1UC2YS3389h9wICf3IGCtmRgkGDqASPTruntQixNMq3qqIkYtUmUXfG2tCDpBjnSCkiqExKjvHVfe6tVFbUrFuzUziJLX4_nOl32hYXRUGyzrAeEPieqIGzQvi2rq3OTKD7aqZJvW-E9Wlo1879KpeZsdxESyNbng5ypTx2g3mgRqA7PVde3U41knXp8yMiA8sVpOquSnxhANm00) Это абстрактный класс который необходимо переопределить в классе наследнике. Разберём его подробнее: - Конструктор класса: - Содержит всего один аргумент: **strict**, он влияет на то, как реагировать на открытие пользователем нового соединения, так как по умолчанию предполагается что пользователь будет использовать только одно соедниение для взаимодействия с базой данных. Если **strict** = True, то в случае если класс уже хранит ссылку на существующее соедниение (_conn != None), то выполнение кода завершится с ошибкой. Если **strict** = False, то старое соединение закроется и класс создаст новое соедниение с базой данных. - *connect()* это контекстно независимый метод для создания соединения с базой данных. Внутри себя он вызывает приватный метод *_connect()*, который является контекстно зависимым от диалекта SQL и должен быть переопределён пользователем в классе наследнике - В методе *_connect()* пользователь реализует специфичную для используемого им диалекта SQL логику подключения к базе данных. Метод должен возвращать объект содениения, как правило он выглядит как: ```your_sql_dialect_connection.connect(**DB_CONFIG)``` - *disconnect()* это контекстно независимый метод для ручного закрытия соединения с базой данных - *connection* это геттер для поля _conn, возвращает текущее соедниение с базой данных. - *execute()* это контекстно независимый метод для выполнения кода базы данных, состоит из открытия курсора соединения и выполнения запроса пользователя через курсор, состоит из нескольких компонентов: - *CursorContext* - контекстно независимый класс-обёртка над объектом курсора, необходим для совместимости и использовании курсора в контекстном менеджере, проксирует все методы курсора делая его программно эквивалентом объекта курсора - *placeholder* - контекстно зависимый геттер для placeholder'а специфичного диалекта SQL для подстановки значений, должен быть переопределён пользователем. - Аргументы метода: execute() принимает всего 2 аргумента: autocommit(bool) и params(tuple). Первый регулирует поведение базы данных при изменении данных - применять ли их сразу или просить пользтвателя сделать это вручную. Второй аргумент передаёт значения для подстановки. - В случае если выполнение запроса завершится с ошибкой, метод автоматически откатит изменения в базе данных (если они были) #### Модели базы данных Как известно, взаимодействия между базой данных и фронтендом в PyQt6 реализовано через систему моделей (model) и представлений (view), в данной библиотеке заного определены модели для таблиц, списков и карточек, реализующие совместимый интерфейс для логики организации управления базами данных данной библиотеки, описанной выше. Логика моделей в библиотеке представлена 3-мя классами модели и одним вспомогательным классом миксином: - *DataMixin* Миксин для моделей Qt (QAbstractTableModel / QAbstractListModel). Содержит общую логику хранения данных, обновления модели и стилизации строк. Миксин DataMixin используется совместно с Qt-моделями и не предназначен для самостоятельного использования. Он упрощает реализацию общих функций модели, чтобы конкретные классы сосредоточились на бизнес-логике. Предоставляет методы для: - хранения данных (_data) - перезагрузки модели (refresh) - доступа к строкам (row_data) - стилизации (row_background, row_foreground, row_font) - *BaseTableModel* Табличная модель, основанная на списке кортежей (row tuples). Поддерживает отображение данных в таблице с заголовками колонок, доступ к сырым данным и стилизацию ячеек через методы миксина. Предназначена для сценариев, где данные естественно представимы в виде таблицы (например, списки объектов с фиксированными полями). - *BaseListModel* Листовая модель, в которой каждая запись представлена как элемент списка. Отображение текста выполняется через метод row_display(). Подходит для простых списков, комбобоксов и других представлений, где не требуется табличная структура. - *BaseCardModel* Модель для карточных или кастомно отрисованных представлений. Не реализует DisplayRole — визуализация делегируется QStyledItemDelegate. Позволяет строить сложные UI-компоненты (карточки, плитки, кастомные списки), сохраняя доступ к данным через UserRole. ### Composer Класс-композитор — это маршрутизатор приложения и менеджер жизненного цикла окон. Он отвечает за регистрацию окон, навигацию между ними и хранение активного соединения с базой данных. Composer выступает центральным хабом приложения, через который происходит переключение представлений и передача контекста. ![Composer](https://www.plantuml.com/plantuml/svg/NL31RiCW3Btp5LPFYPPgznocIki_x31Do2LMYqGWmyxIhjg_BmkGgU72y_F33xy32qOPUwUCGsPu3VqGc2BS5Snd3xex5MJ66CbBAN4O2enqjYpn1YqShP7t6JSB-jYyrKQkMQIMrXDu_B9d5DAHFaVYbTVQUYjQLxDF0zsfphm9NkWgkKhE52kFFJKmMT-5gG67txTwOr1bWyB7qLVBzdNv94zMp4Md8LMwrgQ9X4AvkTZLwaVbsDuM5kx_p145JyYKk3NM_VwbY5j88ncU8JaJFmhN6IrSWkluRtTFHcpmZyBTDiGTjC3sxD6f0_bkxDql) Разберём его устройство: - Конструктор класса: - Содержит 2 аргумента: **app** и **db**. **app** это ссылка на объект цикла событий PyQt6, **db** это ссылка на объект базы данных. - Внутри себя инициализирует 5 полей: - *_db* - хранит ссылку на объект базы данных - *_current* - хранит ссылку на активное окно - *_app* - хранит ссылку на объект цикла событий PyQt6 - *_registry* - хранит ссылку регистр композитора, который в свою очередь хранит базу данных формата "ключ-значение" (словарь/dict python), содержащую сопоставление строкового названия окна и ссылку на класс окна. - Шаблон записи в регистре композитора определён в методе *register()* и выглядит так: ``` { "class": window, "instance": None, "lazy": lazy } ``` - Поле **class** это ссылка на класс окна - Поле **instance** это ссылка на объект окна (если он создан) - Поле **lazy** это настройка создания объекта окна. Если lazy = True, тогда объект окна создаётся в момент запроса на переключение на это окно. Если lazy = False, тогда объект окна создаётся сразу при регистрации окна в регистре композитора. Первый вариант больше подходит если содержимое окна контекстно зависимо. - *_current_ctx* - хранит контекст активного окна, представляет собой базу данных формата "ключ-значение" (словарь/dict python). Контекст уникален для каждого окна и перезаписывается при переключении окон. Может содержать любые записи "ключ-значение", информация из контекста используется для нужд окна (например, если пользователь использует RBAC как систему аутентификации, логично хранить объект с правами пользователя в контексте окна). Контекст задаётся во время вызова слота *navigate()*. Предполагается что стартовое окно контекст содержать не должно - *register()* - метод создающий новую запись в регистре композитора. Если класс передающийся в аргумент window не является наследником BaseWindow, произойдёт исключение Python. - *navigate_request* - единственный сигнал класса композитора, эмит сигнала должен происходить в случае если пользователю требуется переключить окно, передаёт ссылку на датакласс NavigationRequest. - *navigate()* - метод управляющий переключением окон. Является слотом PyQt6 и напрямую не должен вызываться, так как вызывается автоматически при эмитте сигнала *navigate_request*. - *NavigationRequest* это датакласс, который состоит из полей *target: str* и *context: NavigationContext*, и передаёт контекст запроса на переключение окон, состоящий из строкового названия окна и ссылки на класс окна (и возможно объекта класса окна). В свою очередь, датакласс *NavigationContext* состоит из одного поля *data*, которое хранит контекст окна. Переключение между окнами осуществляется через программный интерфейс класса композитора, ссылку на объект которого по умолчанию додлжны получить все окна (Шаблон окна выше). ## Модуль Contrib Модуль contrib содержит готовые реализации для типовых случаев. В отличие от core, contrib контекстно зависим и предполагает конкретные решения которые пользователь может использовать как есть или взять за основу. ### Backends Готовые реализации AbstractDatabase для трёх диалектов SQL: ```python from pyqt6_scaffold.contrib import PostgresqlDatabase, MysqlDatabase, SqliteDatabase ``` Каждый backend читает конфигурацию из переменных окружения: | Класс | Переменные окружения | |--------------------|--------------------------------------------------------------------| | PostgresqlDatabase | PG_HOST, PG_PORT, PG_USER, PG_DATABASE, PG_PASSWORD | | MysqlDatabase | MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_DATABASE, MYSQL_PASSWORD | | SqliteDatabase | SQLITE_PATH | ### Auth Система авторизации на основе RBAC (Role-Based Access Control): ```python from pyqt6_scaffold.contrib.auth import RBACMixin, Role, RoleLevel ``` **Role** и **RoleLevel** — датакласс и enum для описания ролей пользователя: ```python from pyqt6_scaffold.contrib.auth import Role, RoleLevel from pyqt6_scaffold import BaseUser role = Role(name="Менеджер", level=RoleLevel.MANAGER.value) user = BaseUser(id=1, name="Иван", role=role) ``` **RBACMixin** — миксин добавляющий метод `can()` в вашу базу данных. Предполагает наличие таблицы `permission_map` в базе данных: ```sql CREATE TABLE permission_map ( perm VARCHAR(50), min_level INT ); INSERT INTO permission_map VALUES ('edit.products', 50); INSERT INTO permission_map VALUES ('delete.products', 100); INSERT INTO permission_map VALUES ('view.products', 0); ... ``` Использование: ```python from pyqt6_scaffold.contrib import MysqlDatabase from pyqt6_scaffold.contrib.auth import RBACMixin class AppDatabase(RBACMixin, MysqlDatabase): def auth(self, login, password): ... db = AppDatabase() db.connect() db.can(user, "edit.products") # True если user.role.level >= 50 db.can(user, "delete.products") # False если user.role.level < 100 ``` Названия таблицы и полей можно переопределить: ```python class AppDatabase(RBACMixin, MysqlDatabase): permission_table = "rights" permission_column = "action" level_column = "required_level" ```