Metadata-Version: 2.4
Name: pyqt6-scaffold
Version: 1.0.1
Summary: Lightweight scaffold framework for PyQt6 applications
Author-email: Daniel Haus <daniel.haus@protonmail.com>
License-Expression: GPL-3.0-only
Project-URL: repository, https://codeberg.org/zerumarex/pyqt6-scaffold
Classifier: Topic :: Software Development :: User Interfaces
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Operating System :: OS Independent
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSES/GPL-3.0-only.txt
Requires-Dist: PyQt6>=6.5.0
Provides-Extra: postgres
Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
Provides-Extra: mysql
Requires-Dist: pymysql>=1.0.0; extra == "mysql"
Provides-Extra: all
Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
Requires-Dist: pymysql>=1.0.0; extra == "all"
Dynamic: license-file

# PyQt6 Scaffold

A wrapper for PyQt6 designed for a more convenient workflow.

[Русскоязычная документация](docs/README.ru.md)

## License
This project is licensed under the GPLv3 license.
See the LICENSES/GPL-3.0-only.txt file for details.

This license is required because project depends on
[PyQt6](https://www.riverbankcomputing.com/software/pyqt/), which is
distributed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.txt).

If GPLv3 does not suit your needs, you may purchase a commercial PyQt
license directly from Riverbank Computing. Visit their website for details.

## Installation

```bash
pip install pyqt6-scaffold            # core only
pip install pyqt6-scaffold[postgres]  # with PostgreSQL support
pip install pyqt6-scaffold[mysql]     # with MySQL support
pip install pyqt6-scaffold[all]       # all drivers
```

## Description

The library provides patterns and tools that remove boilerplate from common PyQt6 tasks: database connection management, window navigation, role-based access control, and building list/card UIs backed by a database.

It is divided into two modules:

- **Core** - abstract base classes: `BaseWindow`, `Composer`, `AbstractDatabase`, and the model hierarchy.
- **Contrib** - ready-to-use implementations: database backends, RBAC auth, pre-built window bases, tab primitives, and a configurable card delegate.

---

## Quick Start

The shortest possible application using the full contrib stack.

### 1. Configure environment variables

```bash
# start.sh
export PG_HOST=localhost
export PG_PORT=5432
export PG_USER=postgres
export PG_DATABASE=mydb
export PG_PASSWORD=secret
python main.py
```

### 2. Implement the database

```python
from pyqt6_scaffold import BaseUser
from pyqt6_scaffold.contrib.auth import RBACMixin, Role
from pyqt6_scaffold.contrib.backends import PostgresqlDatabase

class AppDatabase(RBACMixin, PostgresqlDatabase):
    def find_user(self, login, password):
        with self.execute(
            "SELECT u.id, u.name, r.name, r.level"
            " FROM users u JOIN roles r ON r.id = u.role_id"
            " WHERE u.login = %s AND u.password = %s",
            (login, password)
        ) as cursor:
            row = cursor.fetchone()
        if row is None:
            return None
        return BaseUser(id=row[0], name=row[1], role=Role(row[2], row[3]))

    def get_items(self):
        with self.execute("SELECT id, name, description, price FROM item") as c:
            return c.fetchall()
```

### 3. Define a card delegate

```python
from pyqt6_scaffold.contrib.delegates import CardDelegate, Section, AsideBlock

class ItemDelegate(CardDelegate):
    card_height = 100
    body = [
        Section(cols=[1], bold=True),
        Section(cols=[2], prefix="Description: "),
        Section(cols=[3], prefix="Price: ", suffix=" USD",
                price=True, price_discount_col=None),
    ]
```

### 4. Define a tab

```python
from pyqt6_scaffold import BaseCardModel
from pyqt6_scaffold.contrib.tabs import CardListTab

class ItemModel(BaseCardModel):
    pass

class ItemTab(CardListTab):
    model_class    = ItemModel
    delegate_class = ItemDelegate
    count_template = "Items: {n}"
    refresh_label  = "Refresh"

    def _query(self):
        return self._db.get_items()
```

### 5. Define windows

```python
from pyqt6_scaffold.contrib.auth import RoleLevel
from pyqt6_scaffold.contrib.windows import BaseLoginWindow, BaseMainWindow

class LoginWindow(BaseLoginWindow):
    window_title         = "My App - Login"
    login_success_target = "main"

    def _authenticate(self, login, password):
        return self._db.find_user(login, password)

class MainWindow(BaseMainWindow):
    window_title = "My App"
    tabs = [
        ("Items", 0, ItemTab),
    ]
```

### 6. Assemble in 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()
```

---

## Core Module

### BaseWindow

The base class for all application windows. Subclass and override the four template methods - they are called in order during `__init__`.

```python
from pyqt6_scaffold import BaseWindow

class MyWindow(BaseWindow):
    def _define_widgets(self):   ...  # create widgets
    def _tune_layouts(self):     ...  # arrange them
    def _connect_slots(self):    ...  # connect signals
    def _apply_windows_settings(self): ...  # title, size, etc.
```

Inside any method `self._db` is the database object and `self._composer` is the Composer.

### Composer

The application router. Registers windows by name, manages navigation, and holds the active database connection.

```python
from pyqt6_scaffold import Composer, NavigateRequest, NavigationContext

composer = Composer(app=app, db=db)
composer.register("login", LoginWindow)          # lazy=True by default
composer.register("splash", SplashWindow, lazy=False)
composer.run(start="login")
```

Navigation is triggered by emitting a signal - never by calling `navigate()` directly:

```python
self._composer.navigate_request.emit(
    NavigateRequest(
        target="main",
        context=NavigationContext(data={"user": user})
    )
)
```

The context of the current window is accessible via `self._composer.context.data`.

`lazy=True` (default) creates a new window instance on every navigation - the window rebuilds itself from the current context. Use `lazy=False` only for windows that are expensive to construct and have no context dependency.

### AbstractDatabase

Abstract base class for database interaction. Subclass it (or use a contrib backend) and override `_connect()` and `placeholder`.

```python
from pyqt6_scaffold import AbstractDatabase

class MyDatabase(AbstractDatabase):
    @property
    def placeholder(self):
        return "%s"

    def _connect(self):
        import psycopg2
        return psycopg2.connect(...)
```

**`execute(sql, params=(), autocommit=False)`** - executes a query and returns a `CursorContext` for use as a context manager. On failure, changes are rolled back automatically.

```python
with self.execute("SELECT * FROM items WHERE id = %s", (item_id,)) as cursor:
    row = cursor.fetchone()

self.execute("INSERT INTO items(name) VALUES(%s)", ("foo",), autocommit=True)
```

### Models

| Class | Use for |
|---|---|
| `BaseTableModel` | tabular data in `QTableView` |
| `BaseListModel` | simple text lists in `QListView` / `QComboBox` |
| `BaseCardModel` | card/custom-drawn views via a delegate |

All three inherit `DataMixin` which provides:

- `refresh(rows)` - replace data and trigger a view update.
- `row_background(data)` / `row_foreground(data)` / `row_font(data)` - override to return a `QColor` or `QFont` for conditional row styling.

```python
from pyqt6_scaffold import BaseCardModel
from PyQt6.QtGui import QColor

class ProductModel(BaseCardModel):
    def row_background(self, data):
        if data[9] == 0:  return QColor("#87CEEB")   # out of stock
        if data[8] > 15:  return QColor("#2E8B57")   # large discount
```

---

## Contrib Module

### Backends

Ready-made `AbstractDatabase` implementations for three SQL dialects.

```python
from pyqt6_scaffold.contrib.backends import PostgresqlDatabase, MysqlDatabase, SqliteDatabase
```

Configuration is read from environment variables:

| Class | Variables |
|---|---|
| `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

```python
from pyqt6_scaffold.contrib.auth import RBACMixin, Role, RoleLevel
```

**`Role(name, level)`** - dataclass describing a user role.

**`RoleLevel`** - enum with predefined levels: `GUEST=0`, `CLIENT=25`, `EMPLOYEE=50`, `MANAGER=75`, `ADMIN=100`.

**`RBACMixin`** - adds `can(user, perm) → bool` to any database class. Requires a `permission_map` table:

```sql
CREATE TABLE permission_map (
    perm      VARCHAR(50) PRIMARY KEY,
    min_level INT NOT NULL
);

INSERT INTO permission_map VALUES ('products.edit',   100);
INSERT INTO permission_map VALUES ('products.filter',  75);
INSERT INTO permission_map VALUES ('orders.view',      75);
```

```python
class AppDatabase(RBACMixin, PostgresqlDatabase):
    ...

db.can(user, "products.edit")    # True if user.role.level >= 100
db.can(user, "products.filter")  # True if user.role.level >= 75
```

Table and column names can be overridden via class attributes:

```python
class AppDatabase(RBACMixin, PostgresqlDatabase):
    permission_table  = "rights"
    permission_column = "action"
    level_column      = "required_level"
```

### Windows

```python
from pyqt6_scaffold.contrib.windows import BaseLoginWindow, BaseMainWindow
```

#### BaseLoginWindow

A login form with username/password fields and an optional guest button. Subclass and set class attributes; override only `_authenticate` and `_create_guest`.

```python
class LoginWindow(BaseLoginWindow):
    window_title          = "My App - Login"
    group_title           = "Sign in to My App"
    login_placeholder     = "Username"
    password_placeholder  = "Password"
    login_btn_label       = "Sign in"
    guest_btn_label       = "Continue as guest"   # empty string = button hidden
    empty_fields_msg      = "Please enter your username and password."
    bad_credentials_msg   = "Invalid username or password."
    login_success_target  = "main"

    def _authenticate(self, login, password):
        return self._db.find_user(login, password)  # return user or None

    def _create_guest(self):
        from pyqt6_scaffold import BaseUser
        from pyqt6_scaffold.contrib.auth import Role, RoleLevel
        return BaseUser(id=0, name="Guest", role=Role("Guest", RoleLevel.GUEST.value))
```

Navigation to `login_success_target` with `{"user": user}` in context happens automatically. Override `_on_login_success(user)` to change this behaviour.

#### BaseMainWindow

A `QMainWindow` with a `QTabWidget` populated from a role-filtered tab registry. User name and a logout button are shown in the status bar.

```python
class MainWindow(BaseMainWindow):
    window_title       = "My App"
    logo_path          = "resources/logo.png"  # shown in status bar; empty = no logo
    logout_label       = "Log out"
    logout_confirm_msg = "Are you sure?"        # empty = no confirmation dialog
    logout_target      = "login"
    tabs = [
        ("Catalogue",  0,   CatalogueTab),
        ("Management", 100, ManagementTab),
        ("Orders",     75,  OrdersTab),
    ]
```

Each entry in `tabs` is a `(title, min_level, TabClass)` triple. A tab is added only when `user.role.level >= min_level`. If the tab list depends on runtime state, override `tabs` as a property:

```python
@property
def tabs(self):
    level = self._user.role.level
    return [
        ("Catalogue", 0, CatalogueTab),
        *([("Management", 100, ManagementTab)] if level >= 100 else []),
    ]
```

### Tabs

```python
from pyqt6_scaffold.contrib.tabs import BaseTab, CardListTab, FilterField, CRUDTab, AnalyticsTab
```

All tab classes follow the same four-method template as `BaseWindow` and receive `db` and `user` in their constructor.

#### BaseTab

Minimal base with `_define_widgets`, `_tune_layouts`, `_connect_slots`, `_load_data`. Use when none of the specialised subclasses fit.

#### CardListTab

A `QListView` driven by a `BaseCardModel` + `CardDelegate` pair, with an optional filter bar built from `FilterField` descriptors.

```python
class ProductListTab(CardListTab):
    model_class    = ProductModel
    delegate_class = ProductDelegate
    permission     = "products.filter"  # filter bar shown only if user has this perm
    count_template = "Found: {n} items" # empty = no count label
    refresh_label  = "Refresh"
    filters = [
        FilterField("search", "_search",
                    placeholder="Search...", stretch=1),
        FilterField("sort", "_sort",
                    choices=[("Default", None), ("Stock ↑", "asc"), ("Stock ↓", "desc")]),
        FilterField("combo", "_supplier",
                    ref_table="supplier", ref_id="supplier_id",
                    ref_name="supplier_name", all_label="All suppliers"),
    ]

    def _query(self):
        return self._db.get_products(
            search=self._filter_value("_search") or "",
            supplier_id=self._filter_value("_supplier"),
            sort_order=self._filter_value("_sort"),
        )
```

**`FilterField` kinds:**

| `kind` | Widget created | Signal connected |
|---|---|---|
| `"search"` | `QLineEdit` | `textChanged` |
| `"combo"` | `QComboBox` populated from `db.get_reference()` | `currentIndexChanged` |
| `"sort"` | `QComboBox` with static `choices` list | `currentIndexChanged` |

All signals trigger `_load_data` → `_query` → `model.refresh`. `_filter_value(attr)` returns `None` for all filters when the user lacks the declared `permission`.

**`selected_row()`** returns the `UserRole` tuple of the currently selected item, or `None`.

#### CRUDTab

`CardListTab` extended with a right-side form (insert / update / delete). The list and form sit inside a horizontal `QSplitter`.

```python
class ProductFormTab(CRUDTab):
    model_class    = ProductModel
    delegate_class = ProductDelegate
    form_title     = "Product"
    add_label      = "Add"
    save_label     = "Save"
    delete_label   = "Delete"
    clear_label    = "Clear"
    no_selection_msg   = "Select a row first."
    delete_confirm_msg = "Delete this record? This cannot be undone."

    def _query(self):
        return self._db.get_products()

    def _define_form_widgets(self):
        self._name = QLineEdit()
        self._price = QDoubleSpinBox()

    def _tune_form_layout(self):
        fl = QFormLayout()
        fl.addRow("Name:",  self._name)
        fl.addRow("Price:", self._price)
        return fl

    def _fill_form(self, row):
        self._name.setText(row[1])
        self._price.setValue(float(row[3]))

    def _clear_form(self):
        self._name.clear()
        self._price.setValue(0)

    def _validate(self):
        if not self._name.text().strip():
            QMessageBox.warning(self, "Error", "Name is required.")
            return False
        return True

    def _get_insert_query(self):
        return ("INSERT INTO product(name, price) VALUES(%s, %s)",
                (self._name.text().strip(), self._price.value()))

    def _get_update_query(self):
        return ("UPDATE product SET name=%s, price=%s WHERE id=%s",
                (self._name.text().strip(), self._price.value(), self._editing_id))

    def _get_delete_query(self, row_id):
        return ("DELETE FROM product WHERE id=%s", (row_id,))
```

When the default `_on_add` / `_on_save` / `_on_delete` handlers are insufficient (e.g. the insert returns a generated ID needed for a second query, or deletion requires a pre-check), override them completely.

`self._editing_id` holds the primary key of the currently selected row (`None` when in add mode).

#### AnalyticsTab

A summary label and two side-by-side read-only `QTableWidget`s.

```python
class SalesTab(AnalyticsTab):
    stats_title   = "By category"
    top_title     = "Top products"
    refresh_label = "Refresh"

    def _load_summary(self):
        with self._db.execute("SELECT COUNT(*) FROM orders") as c:
            self.summary_label.setText(f"Total orders: {c.fetchone()[0]}")

    def _load_stats(self):
        with self._db.execute("SELECT category, COUNT(*) FROM orders GROUP BY 1") as c:
            self._fill_table(self.stats_table, c.fetchall(), ["Category", "Count"])

    def _load_top(self):
        with self._db.execute("SELECT name, revenue FROM top_products LIMIT 10") as c:
            self._fill_table(self.top_table, c.fetchall(), ["Product", "Revenue"])
```

### Delegates

```python
from pyqt6_scaffold.contrib.delegates import CardDelegate, Section, AsideBlock
```

A `QStyledItemDelegate` that renders cards driven entirely by class attributes. Pair it with `BaseCardModel` and `QListView`.

#### Section

One body row of a card.

| Attribute | Type | Description |
|---|---|---|
| `cols` | `list[int]` | Row tuple indices joined into one line |
| `bold` | `bool` | Draw in bold font |
| `prefix` | `str` | Text prepended to the first column value |
| `suffix` | `str` | Text appended to the last column value |
| `separator` | `str` | Separator between multiple columns (default `" "`) |
| `date` | `bool` | Format value as `dd.mm.yyyy` |
| `price` | `bool` | Strikethrough-price mode: old price in red, discounted in black |
| `price_discount_col` | `int \| None` | Index of the discount column (0–100); required with `price=True` |
| `color` | `str \| None` | CSS colour string; `None` = inherit |

#### AsideBlock

Right-side block of a card (discount badge, delivery date, status, etc.).

| Attribute | Type | Description |
|---|---|---|
| `col` | `int` | Row tuple index for the main value |
| `label` | `str` | Small header above the value (`\n` for line breaks) |
| `suffix` | `str` | Text appended to the value |
| `date` | `bool` | Format value as `dd.mm.yyyy` |
| `width` | `int` | Block width in pixels |
| `large` | `bool` | Large bold value (e.g. discount %). `False` = label + value stacked and centred |

#### CardDelegate

```python
class ProductDelegate(CardDelegate):
    card_height = 150
    photo_col   = 10          # row index for image path; None = no photo
    photo_width = 120
    placeholder = "resources/picture.png"

    body = [
        Section(cols=[3, 1], bold=True, separator=" | "),
        Section(cols=[2],    prefix="Description: "),
        Section(cols=[4],    prefix="Manufacturer: "),
        Section(cols=[7],    prefix="Price: ", suffix=" USD",
                price=True,  price_discount_col=8),
        Section(cols=[9],    prefix="In stock: "),
    ]

    aside = AsideBlock(col=8, label="Discount\n", suffix="%", width=110, large=True)
```

Additional class attributes for styling:

| Attribute | Default | Description |
|---|---|---|
| `border_selected` | `"#1F6FB2"` | Border colour when selected |
| `border_normal` | `"#C0C0C0"` | Border colour in default state |
| `bg_default` | `"white"` | Card background when model returns no `BackgroundRole` |
| `padding` | `8` | Inner padding in pixels |

Photo files are cached per subclass. If `photo_col` points to an empty string or an invalid path, `placeholder` is used instead.
