deskkit 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deskkit/__init__.py +44 -0
- deskkit/app.py +114 -0
- deskkit/auth.py +72 -0
- deskkit/catalog.py +103 -0
- deskkit/config.py +130 -0
- deskkit/database.py +211 -0
- deskkit/exceptions.py +17 -0
- deskkit/generator/__init__.py +3 -0
- deskkit/generator/export.py +237 -0
- deskkit/generator/templates/auth.py +70 -0
- deskkit/generator/templates/database.py +200 -0
- deskkit/generator/templates/images_utils.py +115 -0
- deskkit/generator/templates/main.py +72 -0
- deskkit/generator/templates/qt_loader.py +54 -0
- deskkit/images.py +127 -0
- deskkit/qt_backend.py +83 -0
- deskkit/roles.py +20 -0
- deskkit/ui/__init__.py +0 -0
- deskkit/ui/login_window.py +114 -0
- deskkit/ui/main_window.py +289 -0
- deskkit/ui/product_form.py +196 -0
- deskkit/ui/styles.py +58 -0
- deskkit-1.1.0.dist-info/METADATA +185 -0
- deskkit-1.1.0.dist-info/RECORD +27 -0
- deskkit-1.1.0.dist-info/WHEEL +5 -0
- deskkit-1.1.0.dist-info/licenses/LICENSE +21 -0
- deskkit-1.1.0.dist-info/top_level.txt +1 -0
deskkit/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""DeskKit — простые десктоп-приложения на Qt и MySQL."""
|
|
2
|
+
|
|
3
|
+
from deskkit.app import ShopApp, run_app, run_from_json
|
|
4
|
+
from deskkit.auth import AuthService, make_guest, user_display_name
|
|
5
|
+
from deskkit.catalog import apply_catalog_filters, search_products, sort_products
|
|
6
|
+
from deskkit.config import AppConfig
|
|
7
|
+
from deskkit.database import Database
|
|
8
|
+
from deskkit.exceptions import AuthError, DatabaseError, DeskKitError, ValidationError
|
|
9
|
+
from deskkit.generator import export_project, run_generated
|
|
10
|
+
from deskkit.images import load_pixmap, save_product_image, validate_product_data
|
|
11
|
+
from deskkit.qt_backend import backend_name, init_qt
|
|
12
|
+
from deskkit.roles import ADMIN, CLIENT, GUEST, MANAGER
|
|
13
|
+
|
|
14
|
+
__version__ = "1.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ShopApp",
|
|
18
|
+
"AppConfig",
|
|
19
|
+
"Database",
|
|
20
|
+
"AuthService",
|
|
21
|
+
"export_project",
|
|
22
|
+
"run_generated",
|
|
23
|
+
"run_app",
|
|
24
|
+
"run_from_json",
|
|
25
|
+
"apply_catalog_filters",
|
|
26
|
+
"search_products",
|
|
27
|
+
"sort_products",
|
|
28
|
+
"load_pixmap",
|
|
29
|
+
"save_product_image",
|
|
30
|
+
"validate_product_data",
|
|
31
|
+
"make_guest",
|
|
32
|
+
"user_display_name",
|
|
33
|
+
"init_qt",
|
|
34
|
+
"backend_name",
|
|
35
|
+
"ADMIN",
|
|
36
|
+
"CLIENT",
|
|
37
|
+
"GUEST",
|
|
38
|
+
"MANAGER",
|
|
39
|
+
"DeskKitError",
|
|
40
|
+
"DatabaseError",
|
|
41
|
+
"AuthError",
|
|
42
|
+
"ValidationError",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
deskkit/app.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Точка входа — запуск готового приложения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from deskkit import qt_backend
|
|
9
|
+
from deskkit.auth import AuthService
|
|
10
|
+
from deskkit.config import AppConfig
|
|
11
|
+
from deskkit.database import Database
|
|
12
|
+
from deskkit.exceptions import DatabaseError
|
|
13
|
+
from deskkit.images import ensure_images_dir
|
|
14
|
+
from deskkit.ui.login_window import LoginWindow
|
|
15
|
+
from deskkit.ui.main_window import MainWindow
|
|
16
|
+
from deskkit.ui.styles import APP_STYLE
|
|
17
|
+
|
|
18
|
+
qt_backend.init_qt()
|
|
19
|
+
QtWidgets = qt_backend.QtWidgets
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ShopApp:
|
|
23
|
+
"""
|
|
24
|
+
Готовое приложение «из коробки».
|
|
25
|
+
|
|
26
|
+
Пример:
|
|
27
|
+
from deskkit import ShopApp, AppConfig
|
|
28
|
+
|
|
29
|
+
ShopApp(AppConfig(
|
|
30
|
+
title="Обувной магазин",
|
|
31
|
+
database="shoe_store",
|
|
32
|
+
password="root",
|
|
33
|
+
)).run()
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: AppConfig | None = None):
|
|
37
|
+
self.config = config or AppConfig()
|
|
38
|
+
qt_backend.init_qt(self.config.qt_backend)
|
|
39
|
+
self.db = Database(self.config)
|
|
40
|
+
self.auth = AuthService(self.db)
|
|
41
|
+
self._app: QtWidgets.QApplication | None = None
|
|
42
|
+
self._main_window: MainWindow | None = None
|
|
43
|
+
|
|
44
|
+
def run(self) -> int:
|
|
45
|
+
"""Запустить приложение или сгенерировать автономный проект."""
|
|
46
|
+
if self.config.export_project and not self.config.library_runtime:
|
|
47
|
+
from deskkit.generator import export_project, run_generated
|
|
48
|
+
|
|
49
|
+
out = export_project(self.config)
|
|
50
|
+
print(f"Проект создан: {out}")
|
|
51
|
+
if self.config.run_generated:
|
|
52
|
+
return run_generated(self.config, out)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
return self._run_library()
|
|
56
|
+
|
|
57
|
+
def _run_library(self) -> int:
|
|
58
|
+
"""Запуск через deskkit (режим разработки)."""
|
|
59
|
+
ensure_images_dir(self.config)
|
|
60
|
+
|
|
61
|
+
self._app = QtWidgets.QApplication.instance()
|
|
62
|
+
if self._app is None:
|
|
63
|
+
self._app = QtWidgets.QApplication(sys.argv)
|
|
64
|
+
self._app.setStyleSheet(APP_STYLE)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
if not self.db.test_connection():
|
|
68
|
+
QtWidgets.QMessageBox.critical(
|
|
69
|
+
None,
|
|
70
|
+
"Ошибка подключения",
|
|
71
|
+
"Не удалось подключиться к базе данных.\n"
|
|
72
|
+
"Проверьте настройки AppConfig.",
|
|
73
|
+
)
|
|
74
|
+
return 1
|
|
75
|
+
except DatabaseError as exc:
|
|
76
|
+
QtWidgets.QMessageBox.critical(None, "Ошибка подключения", str(exc))
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
if not self._show_login():
|
|
80
|
+
return 0
|
|
81
|
+
return qt_backend.exec_app(self._app)
|
|
82
|
+
|
|
83
|
+
def _show_login(self) -> bool:
|
|
84
|
+
def on_success(user: dict) -> None:
|
|
85
|
+
self._show_main()
|
|
86
|
+
|
|
87
|
+
login = LoginWindow(self.config, self.auth, on_success)
|
|
88
|
+
result = login.exec()
|
|
89
|
+
return result == QtWidgets.QDialog.DialogCode.Accepted
|
|
90
|
+
|
|
91
|
+
def _show_main(self) -> None:
|
|
92
|
+
if self._main_window:
|
|
93
|
+
self._main_window.close()
|
|
94
|
+
|
|
95
|
+
def on_logout() -> None:
|
|
96
|
+
self._show_login()
|
|
97
|
+
|
|
98
|
+
self._main_window = MainWindow(
|
|
99
|
+
self.config,
|
|
100
|
+
self.db,
|
|
101
|
+
self.auth,
|
|
102
|
+
on_logout=on_logout,
|
|
103
|
+
)
|
|
104
|
+
self._main_window.show()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_app(config: AppConfig | None = None) -> int:
|
|
108
|
+
"""Быстрый запуск приложения одной функцией."""
|
|
109
|
+
return ShopApp(config).run()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_from_json(path: str) -> int:
|
|
113
|
+
"""Запуск по JSON-файлу настроек."""
|
|
114
|
+
return ShopApp(AppConfig.from_json(path)).run()
|
deskkit/auth.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Авторизация пользователей."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from deskkit.database import Database
|
|
8
|
+
from deskkit.exceptions import AuthError
|
|
9
|
+
from deskkit.roles import ADMIN, CLIENT, GUEST, MANAGER
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_guest() -> dict[str, Any]:
|
|
13
|
+
return {
|
|
14
|
+
"id_user": 0,
|
|
15
|
+
"login": "guest",
|
|
16
|
+
"first_name": "Гость",
|
|
17
|
+
"last_name": "",
|
|
18
|
+
"middle_name": None,
|
|
19
|
+
"id_role": 0,
|
|
20
|
+
"role_name": GUEST,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_display_name(user: dict[str, Any]) -> str:
|
|
25
|
+
parts = [user.get("last_name", ""), user.get("first_name", "")]
|
|
26
|
+
middle = user.get("middle_name")
|
|
27
|
+
if middle:
|
|
28
|
+
parts.append(middle)
|
|
29
|
+
name = " ".join(p for p in parts if p).strip()
|
|
30
|
+
return name or user.get("login", "Пользователь")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthService:
|
|
34
|
+
def __init__(self, db: Database):
|
|
35
|
+
self.db = db
|
|
36
|
+
self.current_user: dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def role(self) -> str:
|
|
40
|
+
if not self.current_user:
|
|
41
|
+
return GUEST
|
|
42
|
+
return str(self.current_user.get("role_name", GUEST))
|
|
43
|
+
|
|
44
|
+
def login(self, login: str, password: str) -> dict[str, Any]:
|
|
45
|
+
if not login.strip() or not password.strip():
|
|
46
|
+
raise AuthError("Введите логин и пароль")
|
|
47
|
+
|
|
48
|
+
user = self.db.authenticate(login.strip(), password)
|
|
49
|
+
if not user:
|
|
50
|
+
raise AuthError("Неверный логин или пароль")
|
|
51
|
+
|
|
52
|
+
self.current_user = user
|
|
53
|
+
return user
|
|
54
|
+
|
|
55
|
+
def login_as_guest(self) -> dict[str, Any]:
|
|
56
|
+
self.current_user = make_guest()
|
|
57
|
+
return self.current_user
|
|
58
|
+
|
|
59
|
+
def logout(self) -> None:
|
|
60
|
+
self.current_user = None
|
|
61
|
+
|
|
62
|
+
def is_admin(self) -> bool:
|
|
63
|
+
return self.role == ADMIN
|
|
64
|
+
|
|
65
|
+
def is_manager(self) -> bool:
|
|
66
|
+
return self.role == MANAGER
|
|
67
|
+
|
|
68
|
+
def is_client(self) -> bool:
|
|
69
|
+
return self.role == CLIENT
|
|
70
|
+
|
|
71
|
+
def is_guest(self) -> bool:
|
|
72
|
+
return self.role == GUEST
|
deskkit/catalog.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Поиск, фильтрация и сортировка товаров."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _text(value: Any) -> str:
|
|
9
|
+
if value is None:
|
|
10
|
+
return ""
|
|
11
|
+
return str(value).lower()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def search_products(
|
|
15
|
+
products: list[dict[str, Any]],
|
|
16
|
+
query: str,
|
|
17
|
+
fields: list[str] | None = None,
|
|
18
|
+
) -> list[dict[str, Any]]:
|
|
19
|
+
"""Поиск в реальном времени по текстовым полям."""
|
|
20
|
+
if not query.strip():
|
|
21
|
+
return list(products)
|
|
22
|
+
|
|
23
|
+
q = query.strip().lower()
|
|
24
|
+
if fields is None:
|
|
25
|
+
fields = [
|
|
26
|
+
"article_number",
|
|
27
|
+
"product_name",
|
|
28
|
+
"description",
|
|
29
|
+
"category_name",
|
|
30
|
+
"manufacturer_name",
|
|
31
|
+
"supplier_name",
|
|
32
|
+
"unit",
|
|
33
|
+
"availability_status",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
result = []
|
|
37
|
+
for product in products:
|
|
38
|
+
for field in fields:
|
|
39
|
+
if q in _text(product.get(field)):
|
|
40
|
+
result.append(product)
|
|
41
|
+
break
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def filter_by_supplier(
|
|
46
|
+
products: list[dict[str, Any]],
|
|
47
|
+
supplier_id: int | None,
|
|
48
|
+
) -> list[dict[str, Any]]:
|
|
49
|
+
if supplier_id is None or supplier_id <= 0:
|
|
50
|
+
return list(products)
|
|
51
|
+
return [p for p in products if int(p.get("id_supplier") or 0) == supplier_id]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def filter_by_category(
|
|
55
|
+
products: list[dict[str, Any]],
|
|
56
|
+
category_id: int | None,
|
|
57
|
+
) -> list[dict[str, Any]]:
|
|
58
|
+
if category_id is None or category_id <= 0:
|
|
59
|
+
return list(products)
|
|
60
|
+
return [p for p in products if int(p.get("id_category") or 0) == category_id]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sort_products(
|
|
64
|
+
products: list[dict[str, Any]],
|
|
65
|
+
field: str = "stock_quantity",
|
|
66
|
+
ascending: bool = True,
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
def key_fn(item: dict[str, Any]) -> Any:
|
|
69
|
+
value = item.get(field, 0)
|
|
70
|
+
if value is None:
|
|
71
|
+
return 0
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
return sorted(products, key=key_fn, reverse=not ascending)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def apply_catalog_filters(
|
|
78
|
+
products: list[dict[str, Any]],
|
|
79
|
+
*,
|
|
80
|
+
search: str = "",
|
|
81
|
+
supplier_id: int | None = None,
|
|
82
|
+
category_id: int | None = None,
|
|
83
|
+
sort_field: str = "product_name",
|
|
84
|
+
sort_asc: bool = True,
|
|
85
|
+
) -> list[dict[str, Any]]:
|
|
86
|
+
"""Комбинирует поиск, фильтры и сортировку."""
|
|
87
|
+
result = search_products(products, search)
|
|
88
|
+
result = filter_by_supplier(result, supplier_id)
|
|
89
|
+
result = filter_by_category(result, category_id)
|
|
90
|
+
return sort_products(result, sort_field, sort_asc)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def row_style(product: dict[str, Any], rules: list[dict[str, Any]]) -> dict[str, str]:
|
|
94
|
+
"""Возвращает стили строки по правилам условного форматирования."""
|
|
95
|
+
styles: dict[str, str] = {}
|
|
96
|
+
for rule in rules:
|
|
97
|
+
condition: Callable[[dict[str, Any]], bool] = rule["when"]
|
|
98
|
+
if condition(product):
|
|
99
|
+
if "background" in rule:
|
|
100
|
+
styles["background"] = rule["background"]
|
|
101
|
+
if "foreground" in rule:
|
|
102
|
+
styles["foreground"] = rule["foreground"]
|
|
103
|
+
return styles
|
deskkit/config.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Конфигурация приложения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AppConfig:
|
|
13
|
+
"""Настройки приложения — достаточно заполнить и передать в ShopApp."""
|
|
14
|
+
|
|
15
|
+
title: str = "Каталог товаров"
|
|
16
|
+
host: str = "localhost"
|
|
17
|
+
port: int = 3306
|
|
18
|
+
user: str = "root"
|
|
19
|
+
password: str = ""
|
|
20
|
+
database: str = "shoe_store"
|
|
21
|
+
|
|
22
|
+
images_dir: str = "images"
|
|
23
|
+
placeholder_image: str = "picture.png"
|
|
24
|
+
|
|
25
|
+
qt_backend: str | None = None # 'pyside6' | 'pyqt6' | None
|
|
26
|
+
|
|
27
|
+
# Поля каталога (9 полей для отображения)
|
|
28
|
+
catalog_columns: list[str] = field(
|
|
29
|
+
default_factory=lambda: [
|
|
30
|
+
"photo",
|
|
31
|
+
"article_number",
|
|
32
|
+
"product_name",
|
|
33
|
+
"category_name",
|
|
34
|
+
"manufacturer_name",
|
|
35
|
+
"supplier_name",
|
|
36
|
+
"price",
|
|
37
|
+
"unit",
|
|
38
|
+
"stock_quantity",
|
|
39
|
+
"discount_percent",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
column_labels: dict[str, str] = field(
|
|
44
|
+
default_factory=lambda: {
|
|
45
|
+
"photo": "Фото",
|
|
46
|
+
"article_number": "Артикул",
|
|
47
|
+
"product_name": "Наименование",
|
|
48
|
+
"category_name": "Категория",
|
|
49
|
+
"manufacturer_name": "Производитель",
|
|
50
|
+
"supplier_name": "Поставщик",
|
|
51
|
+
"price": "Цена",
|
|
52
|
+
"unit": "Ед. изм.",
|
|
53
|
+
"stock_quantity": "Кол-во",
|
|
54
|
+
"discount_percent": "Скидка %",
|
|
55
|
+
"description": "Описание",
|
|
56
|
+
"availability_status": "Наличие",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Условное форматирование строк каталога
|
|
61
|
+
format_rules: list[dict[str, Any]] = field(
|
|
62
|
+
default_factory=lambda: [
|
|
63
|
+
{
|
|
64
|
+
"name": "discount",
|
|
65
|
+
"when": lambda p: float(p.get("discount_percent") or 0) > 0,
|
|
66
|
+
"background": "#FFF5F5",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "out_of_stock",
|
|
70
|
+
"when": lambda p: int(p.get("stock_quantity") or 0) == 0,
|
|
71
|
+
"background": "#ECECEC",
|
|
72
|
+
"foreground": "#888888",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "big_discount",
|
|
76
|
+
"when": lambda p: float(p.get("discount_percent") or 0) >= 15,
|
|
77
|
+
"background": "#E8F5E9",
|
|
78
|
+
"foreground": "#2E8B57",
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
image_width: int = 300
|
|
84
|
+
image_height: int = 200
|
|
85
|
+
|
|
86
|
+
# --- Экспорт автономного проекта (по умолчанию включён) ---
|
|
87
|
+
export_project: bool = True
|
|
88
|
+
"""True: сгенерировать готовый проект без импортов deskkit."""
|
|
89
|
+
output_dir: str = ""
|
|
90
|
+
"""Папка результата. Пусто — имя из title."""
|
|
91
|
+
project_name: str = ""
|
|
92
|
+
"""Имя папки проекта (slug)."""
|
|
93
|
+
include_seed_data: bool = True
|
|
94
|
+
"""Добавить тестовые данные в sql/database.sql."""
|
|
95
|
+
run_generated: bool = True
|
|
96
|
+
"""После генерации запустить main.py сгенерированного проекта."""
|
|
97
|
+
library_runtime: bool = False
|
|
98
|
+
"""True: запуск через deskkit (без генерации). Приоритет над export_project."""
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_json(cls, path: str | Path) -> "AppConfig":
|
|
102
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
103
|
+
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
|
104
|
+
filtered = {k: v for k, v in data.items() if k in known and k not in ("format_rules",)}
|
|
105
|
+
return cls(**filtered)
|
|
106
|
+
|
|
107
|
+
def save_json(self, path: str | Path) -> None:
|
|
108
|
+
data = {
|
|
109
|
+
"title": self.title,
|
|
110
|
+
"host": self.host,
|
|
111
|
+
"port": self.port,
|
|
112
|
+
"user": self.user,
|
|
113
|
+
"password": self.password,
|
|
114
|
+
"database": self.database,
|
|
115
|
+
"images_dir": self.images_dir,
|
|
116
|
+
"placeholder_image": self.placeholder_image,
|
|
117
|
+
"qt_backend": self.qt_backend,
|
|
118
|
+
"image_width": self.image_width,
|
|
119
|
+
"image_height": self.image_height,
|
|
120
|
+
"export_project": self.export_project,
|
|
121
|
+
"output_dir": self.output_dir,
|
|
122
|
+
"project_name": self.project_name,
|
|
123
|
+
"include_seed_data": self.include_seed_data,
|
|
124
|
+
"run_generated": self.run_generated,
|
|
125
|
+
"library_runtime": self.library_runtime,
|
|
126
|
+
}
|
|
127
|
+
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
def images_path(self) -> Path:
|
|
130
|
+
return Path(self.images_dir)
|
deskkit/database.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Работа с MySQL через mysql.connector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Generator, Iterable
|
|
8
|
+
|
|
9
|
+
import mysql.connector
|
|
10
|
+
from mysql.connector import Error as MySQLError
|
|
11
|
+
|
|
12
|
+
from deskkit.config import AppConfig
|
|
13
|
+
from deskkit.exceptions import DatabaseError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _row_to_dict(cursor: Any, row: tuple) -> dict[str, Any]:
|
|
17
|
+
columns = [desc[0] for desc in cursor.description]
|
|
18
|
+
result: dict[str, Any] = {}
|
|
19
|
+
for key, value in zip(columns, row):
|
|
20
|
+
if isinstance(value, Decimal):
|
|
21
|
+
value = float(value)
|
|
22
|
+
result[key] = value
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Database:
|
|
27
|
+
"""Простой слой доступа к MySQL."""
|
|
28
|
+
|
|
29
|
+
PRODUCTS_QUERY = """
|
|
30
|
+
SELECT
|
|
31
|
+
p.id_product,
|
|
32
|
+
p.article_number,
|
|
33
|
+
p.product_name,
|
|
34
|
+
p.description,
|
|
35
|
+
p.price,
|
|
36
|
+
p.discount_percent,
|
|
37
|
+
p.unit,
|
|
38
|
+
p.stock_quantity,
|
|
39
|
+
p.photo_url,
|
|
40
|
+
p.id_category,
|
|
41
|
+
p.id_supplier,
|
|
42
|
+
p.id_manufacturer,
|
|
43
|
+
c.category_name,
|
|
44
|
+
m.manufacturer_name,
|
|
45
|
+
s.supplier_name,
|
|
46
|
+
CASE
|
|
47
|
+
WHEN p.stock_quantity = 0 THEN 'Нет в наличии'
|
|
48
|
+
WHEN p.stock_quantity <= 5 THEN 'Мало в наличии'
|
|
49
|
+
ELSE 'В наличии'
|
|
50
|
+
END AS availability_status
|
|
51
|
+
FROM products p
|
|
52
|
+
LEFT JOIN categories c ON p.id_category = c.id_category
|
|
53
|
+
LEFT JOIN manufacturers m ON p.id_manufacturer = m.id_manufacturer
|
|
54
|
+
LEFT JOIN suppliers s ON p.id_supplier = s.id_supplier
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: AppConfig):
|
|
58
|
+
self.config = config
|
|
59
|
+
|
|
60
|
+
def _connect(self) -> mysql.connector.MySQLConnection:
|
|
61
|
+
try:
|
|
62
|
+
return mysql.connector.connect(
|
|
63
|
+
host=self.config.host,
|
|
64
|
+
port=self.config.port,
|
|
65
|
+
user=self.config.user,
|
|
66
|
+
password=self.config.password,
|
|
67
|
+
database=self.config.database,
|
|
68
|
+
charset="utf8mb4",
|
|
69
|
+
use_unicode=True,
|
|
70
|
+
)
|
|
71
|
+
except MySQLError as exc:
|
|
72
|
+
raise DatabaseError(f"Не удалось подключиться к MySQL: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def connection(self) -> Generator[mysql.connector.MySQLConnection, None, None]:
|
|
76
|
+
conn = self._connect()
|
|
77
|
+
try:
|
|
78
|
+
yield conn
|
|
79
|
+
finally:
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def cursor(self, dictionary: bool = False) -> Generator[Any, None, None]:
|
|
84
|
+
with self.connection() as conn:
|
|
85
|
+
cur = conn.cursor(dictionary=dictionary)
|
|
86
|
+
try:
|
|
87
|
+
yield cur
|
|
88
|
+
conn.commit()
|
|
89
|
+
except Exception:
|
|
90
|
+
conn.rollback()
|
|
91
|
+
raise
|
|
92
|
+
finally:
|
|
93
|
+
cur.close()
|
|
94
|
+
|
|
95
|
+
def test_connection(self) -> bool:
|
|
96
|
+
with self.connection() as conn:
|
|
97
|
+
return conn.is_connected()
|
|
98
|
+
|
|
99
|
+
def fetch_all(self, query: str, params: Iterable[Any] | None = None) -> list[dict[str, Any]]:
|
|
100
|
+
with self.cursor() as cur:
|
|
101
|
+
cur.execute(query, params or ())
|
|
102
|
+
rows = cur.fetchall()
|
|
103
|
+
return [_row_to_dict(cur, row) for row in rows]
|
|
104
|
+
|
|
105
|
+
def fetch_one(self, query: str, params: Iterable[Any] | None = None) -> dict[str, Any] | None:
|
|
106
|
+
rows = self.fetch_all(query, params)
|
|
107
|
+
return rows[0] if rows else None
|
|
108
|
+
|
|
109
|
+
def execute(self, query: str, params: Iterable[Any] | None = None) -> int:
|
|
110
|
+
with self.cursor() as cur:
|
|
111
|
+
cur.execute(query, params or ())
|
|
112
|
+
return cur.rowcount
|
|
113
|
+
|
|
114
|
+
# --- Пользователи ---
|
|
115
|
+
|
|
116
|
+
def authenticate(self, login: str, password: str) -> dict[str, Any] | None:
|
|
117
|
+
query = """
|
|
118
|
+
SELECT
|
|
119
|
+
u.id_user, u.login, u.first_name, u.last_name, u.middle_name,
|
|
120
|
+
r.id_role, r.role_name
|
|
121
|
+
FROM users u
|
|
122
|
+
JOIN roles r ON u.id_role = r.id_role
|
|
123
|
+
WHERE u.login = %s AND u.password_hash = %s
|
|
124
|
+
"""
|
|
125
|
+
return self.fetch_one(query, (login, password))
|
|
126
|
+
|
|
127
|
+
# --- Товары ---
|
|
128
|
+
|
|
129
|
+
def get_products(self) -> list[dict[str, Any]]:
|
|
130
|
+
return self.fetch_all(self.PRODUCTS_QUERY + " ORDER BY p.product_name")
|
|
131
|
+
|
|
132
|
+
def get_product(self, product_id: int) -> dict[str, Any] | None:
|
|
133
|
+
return self.fetch_one(
|
|
134
|
+
self.PRODUCTS_QUERY + " WHERE p.id_product = %s",
|
|
135
|
+
(product_id,),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def create_product(self, data: dict[str, Any]) -> int:
|
|
139
|
+
query = """
|
|
140
|
+
INSERT INTO products (
|
|
141
|
+
article_number, product_name, description, price,
|
|
142
|
+
discount_percent, unit, stock_quantity, photo_url,
|
|
143
|
+
id_category, id_manufacturer, id_supplier
|
|
144
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
145
|
+
"""
|
|
146
|
+
params = (
|
|
147
|
+
data["article_number"],
|
|
148
|
+
data["product_name"],
|
|
149
|
+
data.get("description"),
|
|
150
|
+
data["price"],
|
|
151
|
+
data.get("discount_percent", 0),
|
|
152
|
+
data.get("unit", "шт."),
|
|
153
|
+
data["stock_quantity"],
|
|
154
|
+
data.get("photo_url"),
|
|
155
|
+
data["id_category"],
|
|
156
|
+
data["id_manufacturer"],
|
|
157
|
+
data["id_supplier"],
|
|
158
|
+
)
|
|
159
|
+
with self.cursor() as cur:
|
|
160
|
+
cur.execute(query, params)
|
|
161
|
+
return int(cur.lastrowid)
|
|
162
|
+
|
|
163
|
+
def update_product(self, product_id: int, data: dict[str, Any]) -> bool:
|
|
164
|
+
query = """
|
|
165
|
+
UPDATE products SET
|
|
166
|
+
article_number = %s, product_name = %s, description = %s,
|
|
167
|
+
price = %s, discount_percent = %s, unit = %s,
|
|
168
|
+
stock_quantity = %s, photo_url = %s,
|
|
169
|
+
id_category = %s, id_manufacturer = %s, id_supplier = %s
|
|
170
|
+
WHERE id_product = %s
|
|
171
|
+
"""
|
|
172
|
+
params = (
|
|
173
|
+
data["article_number"],
|
|
174
|
+
data["product_name"],
|
|
175
|
+
data.get("description"),
|
|
176
|
+
data["price"],
|
|
177
|
+
data.get("discount_percent", 0),
|
|
178
|
+
data.get("unit", "шт."),
|
|
179
|
+
data["stock_quantity"],
|
|
180
|
+
data.get("photo_url"),
|
|
181
|
+
data["id_category"],
|
|
182
|
+
data["id_manufacturer"],
|
|
183
|
+
data["id_supplier"],
|
|
184
|
+
product_id,
|
|
185
|
+
)
|
|
186
|
+
return self.execute(query, params) > 0
|
|
187
|
+
|
|
188
|
+
def delete_product(self, product_id: int) -> bool:
|
|
189
|
+
return self.execute("DELETE FROM products WHERE id_product = %s", (product_id,)) > 0
|
|
190
|
+
|
|
191
|
+
def product_in_orders(self, product_id: int) -> bool:
|
|
192
|
+
row = self.fetch_one(
|
|
193
|
+
"SELECT COUNT(*) AS cnt FROM order_items WHERE id_product = %s",
|
|
194
|
+
(product_id,),
|
|
195
|
+
)
|
|
196
|
+
return bool(row and int(row["cnt"]) > 0)
|
|
197
|
+
|
|
198
|
+
# --- Справочники ---
|
|
199
|
+
|
|
200
|
+
def get_categories(self) -> list[dict[str, Any]]:
|
|
201
|
+
return self.fetch_all("SELECT id_category, category_name FROM categories ORDER BY category_name")
|
|
202
|
+
|
|
203
|
+
def get_manufacturers(self) -> list[dict[str, Any]]:
|
|
204
|
+
return self.fetch_all(
|
|
205
|
+
"SELECT id_manufacturer, manufacturer_name FROM manufacturers ORDER BY manufacturer_name"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def get_suppliers(self) -> list[dict[str, Any]]:
|
|
209
|
+
return self.fetch_all(
|
|
210
|
+
"SELECT id_supplier, supplier_name FROM suppliers ORDER BY supplier_name"
|
|
211
|
+
)
|
deskkit/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Исключения библиотеки."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeskKitError(Exception):
|
|
5
|
+
"""Базовая ошибка DeskKit."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseError(DeskKitError):
|
|
9
|
+
"""Ошибка работы с базой данных."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationError(DeskKitError):
|
|
13
|
+
"""Ошибка валидации данных."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(DeskKitError):
|
|
17
|
+
"""Ошибка авторизации."""
|