Initial Commit

This commit is contained in:
helldh 2026-02-27 23:18:40 +03:00
commit 18d456bf2e
20 changed files with 1442 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.venv
__pycache__
*.pyc

3
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

12
.idea/master.iml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

4
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/master.iml" filepath="$PROJECT_DIR$/.idea/master.iml" />
</modules>
</component>
</project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

57
composer.py Normal file
View file

@ -0,0 +1,57 @@
from src.windows import LoginWindow, MainWindow
from src.objects import User
from src.db import DB_CONFIG
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
from PyQt6.QtSql import QSqlDatabase
class Composer(QObject):
"""Управляет навигацией между окнами и жизненным циклом приложения"""
render_request = pyqtSignal(User)
def __init__(self):
super().__init__()
self._current = None
self._app = QApplication([])
self._init_db()
self.render_request.connect(self._render)
def _init_db(self):
"""Инициализация Qt SQL соединения (используется QSqlTableModel)"""
self._db = QSqlDatabase.addDatabase("QPSQL")
self._db.setDatabaseName(DB_CONFIG['dbname'])
self._db.setPort(DB_CONFIG['port'])
self._db.setHostName(DB_CONFIG['host'])
self._db.setUserName(DB_CONFIG['user'])
self._db.setPassword(DB_CONFIG['password'])
if not self._db.open():
raise Exception(
f"Не удалось подключиться к БД: {self._db.lastError().text()}"
)
@pyqtSlot(User)
def _render(self, user: User):
"""Маршрутизация: все роли идут в MainWindow, права применяются внутри"""
self._main_fabric(user)
def _login_fabric(self):
self.wlogin = LoginWindow(self, self._db)
self._switch_window(self.wlogin)
def _main_fabric(self, user: User):
self.wmain = MainWindow(self, self._db, user)
self._switch_window(self.wmain)
def _switch_window(self, new_window):
if self._current:
self._current.close()
new_window.show()
self._current = new_window
def run(self):
import sys
self._login_fabric()
sys.exit(self._app.exec())

8
main.py Normal file
View file

@ -0,0 +1,8 @@
from composer import Composer
def main():
composer = Composer()
composer.run()
if __name__ == "__main__":
main()

139
postgresql.sql Normal file
View file

@ -0,0 +1,139 @@
create extension pgcrypto;
create domain email as varchar(600)
check (
value ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
);
create domain phone as varchar(50)
check (
value ~ '^\+[0-9]{10,15}$'
);
create type rent_status as enum ('Новая', 'Подтверждена', 'Выдана', 'Завершена', 'Отменена');
create table cmd_type(
id serial primary key,
type varchar(300)
);
create table manufacturer(
id serial primary key,
name varchar(300)
);
create table pu_point(
id serial primary key,
name varchar(300),
address varchar(600)
);
create table commodity(
id serial primary key,
inv_number char(4),
supply_name varchar,
supply_type int references cmd_type(id),
rent decimal(10,2),
manufacturer int references manufacturer(id),
pick_up_point int references pu_point(id),
img_path text
);
create table client(
id serial primary key,
name varchar(600),
phone phone,
email email
);
create table employee(
id serial primary key,
name varchar(600)
);
create table rent(
id serial primary key,
commodity int references commodity(id),
client int references client(id),
r_start date,
r_end date,
status rent_status,
employee int references employee(id)
);
create table roles(
id serial primary key,
name varchar(300),
level int
);
create table users(
id serial primary key,
name varchar(300),
role int references roles(id),
hash text not null
);
create table permission_map(
id serial primary key,
perm varchar(300),
req_level int
);
insert into cmd_type(type) values
('Велосипед'), ('Самокат'), ('Лыжи'), ('Ролики'), ('Сноуборд');
insert into manufacturer(name) values
('Trek'), ('UrbanRide'), ('SnowFast'),
('SpeedRun'), ('Giant'), ('Arctic');
insert into pu_point(name, address) values
('Центральный', 'ул. Мира 15'),
('Северный', 'пр. Победы 10'),
('Южный', 'ул. Ленина 8');
insert into commodity(inv_number, supply_name,
supply_type, rent, manufacturer,
pick_up_point, img_path) values
('1001', 'Trek X200', 1, 900.00, 1, 1, 'assets/Велосипед TrekX200.jpg'),
('1002', 'Urban Pro', 2, 400.00, 2, 2, 'assets/Самокат Urban Pro.jpg'),
('1003', 'SnowFast 300', 1, 1200.00, 3, 1, 'assets/Лыжи SnowFast 300.jpg'),
('1004', 'SpeedRun', 1, 700.00, 4, 3, 'assets/Ролики SpeedRun.jpg'),
('1005', 'Giant ATX', 1, 850.00, 5, 2, 'assets/Велосипед Giant ATX.jpg'),
('1006', 'Arctic Pro', 1, 1500.00, 6, 3, 'assets/Сноуборд Arctic Pro.jpg');
insert into client(name, phone, email) values
('Иванов И.И.', '+79997776655', 'ivan@mail.ru'),
('Сидоров С.С.', '+79887776655', 'sid@mail.ru'),
('Кузнецов К.К.', '+7776655443', 'kuz@mail.ru'),
('Смирнова А.А.', '+79665554433', 'smirnova@mail.ru'),
('Васильев Д.Д.', '+79554443322', 'vasiliev@mail.ru');
insert into employee(name) values
('Петров П.П.'), ('Орлов А.А.');
insert into rent(commodity, client, r_start, r_end, status, employee) values
(1, 1, '2024-04-12', '2024-04-15', 'Новая', 1),
(2, 2, '2024-04-10', '2024-04-11', 'Подтверждена', 2),
(3, 1, '2024-04-20', '2024-04-25', 'Выдана', 1),
(4, 3, '2024-04-15', '2024-04-17', 'Завершена', 2),
(5, 4, '2024-04-18', '2024-04-20', 'Отменена', 1),
(6, 5, '2024-04-01', '2024-04-05', 'Новая', 2);
insert into roles(name, level) values
('admin', 100), ('employee', 50), ('client', 25), ('guest', 0);
insert into users(name, role, hash) values
('admin', 1, crypt('admin123', gen_salt('bf'))),
('employee', 2, crypt('employee123', gen_salt('bf'))),
('client1', 3, crypt('client123', gen_salt('bf'))),
('client2', 3, crypt('client321', gen_salt('bf')));
insert into permission_map(perm, req_level) values
('read.equipment', 0),
('read.requests', 50),
('create.equipment', 100),
('create.requests', 25),
('update.equipment', 100),
('update.request.status', 50),
('delete.equipment', 100);

0
src/__init__.py Normal file
View file

311
src/db.py Normal file
View file

@ -0,0 +1,311 @@
import psycopg2 as pg
from .objects import User, Role
DB_CONFIG = {
"host": "127.0.0.1",
"port": 5432,
"dbname": "example3",
"user": "postgres",
"password": "1q2w3e4r%TGB###"
}
def get_connection():
"""Получить подключение к базе данных"""
return pg.connect(**DB_CONFIG)
def do_request(autocommit=False):
"""
Декоратор для выполнения SQL запросов.
Автоматически управляет соединением и курсором.
"""
def upper_wrapper(func):
def wrapper(*args, **kwargs):
conn = get_connection()
cursor = conn.cursor()
try:
kwargs['cursor'] = cursor
result = func(*args, **kwargs)
if autocommit:
conn.commit()
return result
except Exception as e:
print(f"Error: Database request failed: {e}")
conn.rollback()
return None
finally:
cursor.close()
conn.close()
return wrapper
return upper_wrapper
def can(user: User, permission: str) -> bool:
"""
Проверить, имеет ли пользователь право на действие.
Сверяется с таблицей permission_map в БД.
"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT req_level FROM permission_map WHERE perm = %s",
(permission,)
)
row = cursor.fetchone()
if not row:
return False
return user.role.level >= row[0]
except Exception as e:
print(f"Error: permission check failed: {e}")
return False
finally:
cursor.close()
conn.close()
@do_request()
def auth(login: str, password: str, *, cursor) -> User | None:
"""Аутентификация пользователя через bcrypt (pgcrypto)"""
cursor.execute(
"""
SELECT u.id, u.name, r.name, r.level
FROM users u
JOIN roles r ON r.id = u.role
WHERE u.name = %s
AND u.hash = crypt(%s, u.hash)
""",
(login, password)
)
row = cursor.fetchone()
if not row:
return None
role = Role(name=row[2], level=row[3])
return User(id=row[0], name=row[1], role=role)
@do_request()
def get_equipment(search: str = "", *, cursor) -> list[tuple]:
"""
Получить список снаряжения.
Возвращает: id, inv_number, supply_name, type, rent,
manufacturer, pickup_point, img_path, is_available
is_available = True если нет активной аренды (Новая/Подтверждена/Выдана)
"""
like = f"%{search.lower()}%"
cursor.execute(
"""
SELECT
c.id,
c.inv_number,
c.supply_name,
ct.type,
c.rent,
m.name AS manufacturer,
pp.name AS pick_up_point,
c.img_path,
NOT EXISTS (
SELECT 1 FROM rent r
WHERE r.commodity = c.id
AND r.status IN ('Новая','Подтверждена','Выдана')
) AS is_available
FROM commodity c
LEFT JOIN cmd_type ct ON ct.id = c.supply_type
LEFT JOIN manufacturer m ON m.id = c.manufacturer
LEFT JOIN pu_point pp ON pp.id = c.pick_up_point
WHERE LOWER(c.supply_name) LIKE %s
OR LOWER(ct.type) LIKE %s
ORDER BY c.id
""",
(like, like)
)
return cursor.fetchall()
@do_request()
def get_equipment_by_id(eq_id: int, *, cursor) -> tuple | None:
"""Получить одну запись снаряжения по ID"""
cursor.execute(
"""
SELECT c.id, c.inv_number, c.supply_name,
c.supply_type, c.rent, c.manufacturer,
c.pick_up_point, c.img_path
FROM commodity c
WHERE c.id = %s
""",
(eq_id,)
)
return cursor.fetchone()
@do_request(autocommit=True)
def insert_equipment(inv_number, supply_name, supply_type,
rent, manufacturer, pick_up_point,
img_path="", *, cursor) -> bool:
"""Добавить новое снаряжение"""
cursor.execute(
"""
INSERT INTO commodity
(inv_number, supply_name, supply_type, rent,
manufacturer, pick_up_point, img_path)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(inv_number, supply_name, supply_type, rent,
manufacturer, pick_up_point, img_path)
)
return True
@do_request(autocommit=True)
def update_equipment(eq_id, inv_number, supply_name, supply_type,
rent, manufacturer, pick_up_point,
img_path="", *, cursor) -> bool:
"""Обновить снаряжение"""
cursor.execute(
"""
UPDATE commodity
SET inv_number = %s,
supply_name = %s,
supply_type = %s,
rent = %s,
manufacturer = %s,
pick_up_point= %s,
img_path = %s
WHERE id = %s
""",
(inv_number, supply_name, supply_type, rent,
manufacturer, pick_up_point, img_path, eq_id)
)
return True
@do_request(autocommit=True)
def delete_equipment(eq_id: int, *, cursor) -> bool:
"""Удалить снаряжение"""
cursor.execute("DELETE FROM commodity WHERE id = %s", (eq_id,))
return True
@do_request()
def get_cmd_types(*, cursor) -> list[tuple]:
"""Получить все типы снаряжения"""
cursor.execute("SELECT id, type FROM cmd_type ORDER BY id")
return cursor.fetchall()
@do_request()
def get_manufacturers(*, cursor) -> list[tuple]:
"""Получить всех производителей"""
cursor.execute("SELECT id, name FROM manufacturer ORDER BY id")
return cursor.fetchall()
@do_request()
def get_pickup_points(*, cursor) -> list[tuple]:
"""Получить все пункты выдачи"""
cursor.execute("SELECT id, name FROM pu_point ORDER BY id")
return cursor.fetchall()
@do_request()
def get_clients(*, cursor) -> list[tuple]:
"""Получить всех клиентов (id, name)"""
cursor.execute("SELECT id, name FROM client ORDER BY name")
return cursor.fetchall()
@do_request()
def get_all_rents(status_filter: str | None = None, *, cursor) -> list[tuple]:
"""
Получить все заявки на аренду.
Возвращает: id, commodity_name, client_name, r_start, r_end, status, employee_name
"""
if status_filter and status_filter != "Все":
cursor.execute(
"""
SELECT r.id,
c.supply_name,
cl.name,
r.r_start,
r.r_end,
r.status,
e.name
FROM rent r
JOIN commodity c ON c.id = r.commodity
JOIN client cl ON cl.id = r.client
LEFT JOIN employee e ON e.id = r.employee
WHERE r.status = %s
ORDER BY r.id DESC
""",
(status_filter,)
)
else:
cursor.execute(
"""
SELECT r.id,
c.supply_name,
cl.name,
r.r_start,
r.r_end,
r.status,
e.name
FROM rent r
JOIN commodity c ON c.id = r.commodity
JOIN client cl ON cl.id = r.client
LEFT JOIN employee e ON e.id = r.employee
ORDER BY r.id DESC
"""
)
return cursor.fetchall()
@do_request()
def get_user_rents(user_id: int, *, cursor) -> list[tuple]:
"""
Получить заявки конкретного клиента по его user_id.
Связь: users.name == client.name (упрощённая схема без FK).
"""
cursor.execute(
"""
SELECT r.id,
c.supply_name,
cl.name,
r.r_start,
r.r_end,
r.status,
e.name
FROM rent r
JOIN commodity c ON c.id = r.commodity
JOIN client cl ON cl.id = r.client
LEFT JOIN employee e ON e.id = r.employee
JOIN users u ON u.name = cl.name
WHERE u.id = %s
ORDER BY r.id DESC
""",
(user_id,)
)
return cursor.fetchall()
@do_request(autocommit=True)
def create_rent(commodity_id: int, client_id: int,
r_start: str, r_end: str,
employee_id: int | None = None, *, cursor) -> bool:
"""Создать новую заявку на аренду"""
cursor.execute(
"""
INSERT INTO rent (commodity, client, r_start, r_end, status, employee)
VALUES (%s, %s, %s, %s, 'Новая', %s)
""",
(commodity_id, client_id, r_start, r_end, employee_id)
)
return True
@do_request(autocommit=True)
def update_rent_status(rent_id: int, status: str, *, cursor) -> bool:
"""Обновить статус заявки"""
cursor.execute(
"UPDATE rent SET status = %s WHERE id = %s",
(status, rent_id)
)
return True

20
src/objects.py Normal file
View file

@ -0,0 +1,20 @@
from enum import Enum, auto
from dataclasses import dataclass
class SignalCode(Enum):
"""Коды сигналов для обработки ошибок"""
SIGFALSE = auto() # Неверные данные
SIGERR = auto() # Ошибка валидации
@dataclass
class Role:
"""Модель роли пользователя"""
name: str
level: int
@dataclass
class User:
"""Модель пользователя"""
id: int
name: str
role: Role

76
src/utils.py Normal file
View file

@ -0,0 +1,76 @@
from PyQt6.QtWidgets import (
QWidget,
QTableView,
QHBoxLayout,
QVBoxLayout,
QDateEdit,
QPushButton,
QLabel,
QHeaderView,
QAbstractItemView,
)
from PyQt6.QtSql import QSqlTableModel
class TabWidgetCustom(QWidget):
"""
Универсальный виджет для CRUD операций с таблицей БД.
Отображает данные через QSqlTableModel с тулбаром действий.
"""
def __init__(self, table_name: str, db, show_date_filter=False):
super().__init__()
self._name = table_name
self._db = db
self._show_date_filter = show_date_filter
self._setup()
def _setup(self):
self.root = QVBoxLayout(self)
if self._show_date_filter:
self.header = QHBoxLayout()
self.from_date = QDateEdit()
self.from_date.setCalendarPopup(True)
self.to_date = QDateEdit()
self.to_date.setCalendarPopup(True)
self.button_filter = QPushButton("Filter")
self.button_all = QPushButton("Show All")
self.header.addWidget(QLabel("From:"))
self.header.addWidget(self.from_date)
self.header.addWidget(QLabel("To:"))
self.header.addWidget(self.to_date)
self.header.addWidget(self.button_filter)
self.header.addWidget(self.button_all)
self.root.addLayout(self.header)
self.view = QTableView()
self.view.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self.view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.view.setAlternatingRowColors(True)
self.btoolbar = QHBoxLayout()
self.button_add = QPushButton("+ Добавить")
self.button_del = QPushButton("- Удалить")
self.button_ok = QPushButton("✓ Применить")
self.button_deny = QPushButton("✗ Отменить")
self.button_csv = QPushButton("Экспорт CSV")
for btn in (self.button_add, self.button_del,
self.button_ok, self.button_deny, self.button_csv):
self.btoolbar.addWidget(btn)
self.root.addWidget(self.view)
self.root.addLayout(self.btoolbar)
self._setup_db()
def _setup_db(self):
self.model = QSqlTableModel(db=self._db)
self.model.setTable(self._name)
self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnManualSubmit)
self.model.select()
self.view.setModel(self.model)

795
src/windows.py Normal file
View file

@ -0,0 +1,795 @@
from __future__ import annotations
import os
import csv
from PyQt6.QtWidgets import (
QWidget, QMainWindow, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QAbstractItemView,
QVBoxLayout, QHBoxLayout, QFormLayout,
QGroupBox, QLabel, QPushButton, QLineEdit,
QComboBox, QDateEdit, QDoubleSpinBox,
QMessageBox, QDialog, QDialogButtonBox,
QFileDialog
)
from PyQt6.QtGui import QPixmap, QColor
from PyQt6.QtCore import Qt, QDate, pyqtSignal, pyqtSlot
from .objects import User, SignalCode
from . import db
RENT_STATUSES = ["Новая", "Подтверждена", "Выдана", "Завершена", "Отменена"]
ASSETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets")
def _make_table(headers: list[str]) -> QTableWidget:
"""Вспомогательная функция: создать настроенный QTableWidget"""
t = QTableWidget()
t.setColumnCount(len(headers))
t.setHorizontalHeaderLabels(headers)
t.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
t.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
t.verticalHeader().setVisible(False)
t.setAlternatingRowColors(True)
return t
class BaseWindow(QMainWindow):
"""Базовый класс для всех окон приложения"""
def __init__(self, composer, db_conn, user: User | None = None):
super().__init__()
self._composer = composer
self._db = db_conn
self._user = user
self._define_widgets()
self._tune_layouts()
self._connect_slots()
self._apply_window_settings()
def _define_widgets(self): pass
def _tune_layouts(self): pass
def _connect_slots(self): pass
def _apply_window_settings(self): pass
class LoginWindow(BaseWindow):
"""Окно авторизации с кнопкой гостевого входа"""
login_success = pyqtSignal(User)
login_forbidden = pyqtSignal(SignalCode)
def _define_widgets(self):
self.root = QWidget()
self.auth_form = QGroupBox("Вход в систему")
self.auth_form.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.login_line = QLineEdit()
self.login_line.setFixedWidth(200)
self.login_line.setPlaceholderText("Логин")
self.password_line = QLineEdit()
self.password_line.setFixedWidth(200)
self.password_line.setEchoMode(QLineEdit.EchoMode.Password)
self.password_line.setPlaceholderText("Пароль")
self.auth_button = QPushButton("Войти")
self.guest_button = QPushButton("Продолжить как гость")
def _tune_layouts(self):
form = QFormLayout()
form.addRow("Логин:", self.login_line)
form.addRow("Пароль:", self.password_line)
self.auth_form.setLayout(form)
root_l = QVBoxLayout()
root_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
root_l.addWidget(self.auth_form)
root_l.addWidget(self.auth_button)
root_l.addWidget(self.guest_button)
self.root.setLayout(root_l)
self.setCentralWidget(self.root)
def _connect_slots(self):
self.auth_button.clicked.connect(self._on_auth)
self.guest_button.clicked.connect(self._on_guest)
self.login_success.connect(self._on_login_success)
self.login_forbidden.connect(self._on_login_forbidden)
self.password_line.returnPressed.connect(self._on_auth)
def _on_auth(self):
login = self.login_line.text().strip()
password = self.password_line.text()
if not login or not password:
self.login_forbidden.emit(SignalCode.SIGERR)
return
user = db.auth(login, password)
if not user:
self.login_forbidden.emit(SignalCode.SIGFALSE)
return
self.login_success.emit(user)
def _on_guest(self):
# Гость — пользователь с нулевым уровнем без записи в БД
from .objects import Role
guest_role = Role(name="guest", level=0)
guest_user = User(id=0, name="Гость", role=guest_role)
self.login_success.emit(guest_user)
@pyqtSlot(User)
def _on_login_success(self, user: User):
self._composer.render_request.emit(user)
@pyqtSlot(SignalCode)
def _on_login_forbidden(self, code: SignalCode):
match code:
case SignalCode.SIGFALSE:
QMessageBox.warning(self, "Ошибка", "Неверный логин или пароль")
case SignalCode.SIGERR:
QMessageBox.critical(self, "Ошибка ввода",
"Введите логин и пароль")
def _apply_window_settings(self):
self.setWindowTitle("Вход — Аренда спортивного инвентаря")
self.setFixedSize(320, 200)
class MainWindow(BaseWindow):
"""
Единое окно приложения.
Содержит два таба: «Снаряжение» и «Заявки».
Доступность элементов управления определяется уровнем прав пользователя.
"""
def _define_widgets(self):
self.root = QTabWidget()
self.eq_widget = QWidget()
# Поиск
self.eq_search_input = QLineEdit()
self.eq_search_input.setPlaceholderText("Поиск по названию или типу…")
self.eq_search_btn = QPushButton("Найти")
self.eq_show_all_btn = QPushButton("Показать все")
# Таблица
self.eq_table = _make_table([
"ID", "Инв. №", "Наименование", "Тип",
"Аренда/сут", "Производитель", "Пункт выдачи", "Доступно"
])
# Превью фото
self.eq_img_label = QLabel("Выберите снаряжение")
self.eq_img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.eq_img_label.setFixedHeight(160)
self.eq_img_label.setStyleSheet(
"border: 1px solid #ccc; background: #f5f5f5;"
)
# Кнопки CRUD — только для admin (level=100)
self.eq_add_btn = QPushButton(" Добавить")
self.eq_edit_btn = QPushButton("✏️ Редактировать")
self.eq_del_btn = QPushButton("🗑️ Удалить")
self.eq_csv_btn = QPushButton("Экспорт CSV")
# ── Таб «Заявки» ──────────────────────────────────────
self.rent_widget = QWidget()
self.rent_status_filter = QComboBox()
self.rent_status_filter.addItem("Все")
self.rent_status_filter.addItems(RENT_STATUSES)
self.rent_filter_btn = QPushButton("Применить фильтр")
self.rent_refresh_btn = QPushButton("Обновить")
self.rent_table = _make_table([
"ID", "Снаряжение", "Клиент",
"Начало", "Конец", "Статус", "Сотрудник"
])
# Кнопки управления заявками
self.rent_create_btn = QPushButton(" Новая заявка")
self.rent_approve_btn = QPushButton("✓ Подтвердить")
self.rent_issue_btn = QPushButton("📦 Выдать")
self.rent_complete_btn = QPushButton("✅ Завершить")
self.rent_cancel_btn = QPushButton("✗ Отменить")
self.rent_approve_btn.setStyleSheet("background:#90EE90;")
self.rent_issue_btn.setStyleSheet("background:#87CEEB;")
self.rent_complete_btn.setStyleSheet("background:#98FB98;")
self.rent_cancel_btn.setStyleSheet("background:#FFB6C6;")
def _tune_layouts(self):
# ── Снаряжение ────────────────────────────────────────
eq_layout = QVBoxLayout()
search_row = QHBoxLayout()
search_row.addWidget(self.eq_search_input)
search_row.addWidget(self.eq_search_btn)
search_row.addWidget(self.eq_show_all_btn)
eq_layout.addLayout(search_row)
eq_layout.addWidget(self.eq_table)
eq_layout.addWidget(self.eq_img_label)
eq_btn_row = QHBoxLayout()
eq_btn_row.addWidget(self.eq_add_btn)
eq_btn_row.addWidget(self.eq_edit_btn)
eq_btn_row.addWidget(self.eq_del_btn)
eq_btn_row.addStretch()
eq_btn_row.addWidget(self.eq_csv_btn)
eq_layout.addLayout(eq_btn_row)
self.eq_widget.setLayout(eq_layout)
# ── Заявки ────────────────────────────────────────────
rent_layout = QVBoxLayout()
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("Статус:"))
filter_row.addWidget(self.rent_status_filter)
filter_row.addWidget(self.rent_filter_btn)
filter_row.addWidget(self.rent_refresh_btn)
filter_row.addStretch()
rent_layout.addLayout(filter_row)
rent_layout.addWidget(self.rent_table)
rent_btn_row = QHBoxLayout()
rent_btn_row.addWidget(self.rent_create_btn)
rent_btn_row.addWidget(self.rent_approve_btn)
rent_btn_row.addWidget(self.rent_issue_btn)
rent_btn_row.addWidget(self.rent_complete_btn)
rent_btn_row.addWidget(self.rent_cancel_btn)
rent_btn_row.addStretch()
rent_layout.addLayout(rent_btn_row)
self.rent_widget.setLayout(rent_layout)
# ── Корень ────────────────────────────────────────────
self.root.addTab(self.eq_widget, "🏄 Снаряжение")
self.root.addTab(self.rent_widget, "📋 Заявки")
self.setCentralWidget(self.root)
def _connect_slots(self):
# Снаряжение
self.eq_search_btn.clicked.connect(self._load_equipment)
self.eq_show_all_btn.clicked.connect(self._reset_eq_search)
self.eq_search_input.returnPressed.connect(self._load_equipment)
self.eq_table.selectionModel().selectionChanged.connect(
self._on_eq_selection
)
self.eq_add_btn.clicked.connect(self._eq_add)
self.eq_edit_btn.clicked.connect(self._eq_edit)
self.eq_del_btn.clicked.connect(self._eq_delete)
self.eq_csv_btn.clicked.connect(self._eq_csv_export)
# Заявки
self.rent_filter_btn.clicked.connect(self._load_rents)
self.rent_refresh_btn.clicked.connect(self._load_rents)
self.rent_create_btn.clicked.connect(self._rent_create)
self.rent_approve_btn.clicked.connect(
lambda: self._rent_set_status("Подтверждена")
)
self.rent_issue_btn.clicked.connect(
lambda: self._rent_set_status("Выдана")
)
self.rent_complete_btn.clicked.connect(
lambda: self._rent_set_status("Завершена")
)
self.rent_cancel_btn.clicked.connect(
lambda: self._rent_set_status("Отменена")
)
def _apply_window_settings(self):
role_label = self._user.role.name if self._user else ""
self.setWindowTitle(
f"Аренда спортивного инвентаря | "
f"{self._user.name} [{role_label}]"
)
self.setMinimumSize(1100, 700)
self._apply_permissions()
self._load_equipment()
self._load_rents()
def _apply_permissions(self):
"""Включить/выключить элементы управления согласно правам пользователя"""
user = self._user
# Снаряжение: CRUD только для admin
can_edit_eq = db.can(user, "create.equipment")
self.eq_add_btn.setVisible(can_edit_eq)
self.eq_edit_btn.setVisible(can_edit_eq)
self.eq_del_btn.setVisible(can_edit_eq)
# Заявки: таб виден всем кроме гостя (level 0)
can_see_rents = user.role.level > 0
self.root.setTabVisible(1, can_see_rents)
# Создать заявку: client (25) и выше
can_create_rent = db.can(user, "create.requests")
self.rent_create_btn.setVisible(can_create_rent)
# Менять статус: employee (50) и выше
can_change_status = db.can(user, "update.request.status")
for btn in (self.rent_approve_btn, self.rent_issue_btn,
self.rent_complete_btn, self.rent_cancel_btn):
btn.setVisible(can_change_status)
# ── Снаряжение ────────────────────────────────────────────
def _load_equipment(self):
search = self.eq_search_input.text().strip()
rows = db.get_equipment(search)
self.eq_table.setRowCount(0)
for row_data in (rows or []):
r = self.eq_table.rowCount()
self.eq_table.insertRow(r)
# id, inv_number, supply_name, type, rent,
# manufacturer, pick_up_point, img_path, is_available
display = [
str(row_data[0]), # ID
str(row_data[1]), # inv_number
str(row_data[2]), # supply_name
str(row_data[3] or ""), # type
f"{row_data[4]:.2f}", # rent
str(row_data[5] or ""), # manufacturer
str(row_data[6] or ""), # pick_up_point
"Да" if row_data[8] else "Нет", # is_available
]
for col, val in enumerate(display):
item = QTableWidgetItem(val)
item.setData(Qt.ItemDataRole.UserRole, row_data)
if not row_data[8]: # недоступно — серый фон
item.setBackground(QColor("#d0d0d0"))
self.eq_table.setItem(r, col, item)
def _reset_eq_search(self):
self.eq_search_input.clear()
self._load_equipment()
def _on_eq_selection(self):
row = self.eq_table.currentRow()
if row < 0:
return
item = self.eq_table.item(row, 0)
if not item:
return
row_data = item.data(Qt.ItemDataRole.UserRole)
if row_data:
self._show_eq_image(row_data[7]) # img_path
def _show_eq_image(self, img_path: str):
if not img_path:
self.eq_img_label.setText("Нет изображения")
return
full = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
img_path
)
if os.path.exists(full):
px = QPixmap(full).scaled(
self.eq_img_label.width(),
self.eq_img_label.height(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.eq_img_label.setPixmap(px)
else:
self.eq_img_label.setText(f"Файл не найден:\n{img_path}")
def _selected_eq_data(self) -> tuple | None:
row = self.eq_table.currentRow()
if row < 0:
return None
item = self.eq_table.item(row, 0)
return item.data(Qt.ItemDataRole.UserRole) if item else None
def _eq_add(self):
dlg = EquipmentDialog(parent=self)
if dlg.exec() == QDialog.DialogCode.Accepted:
self._load_equipment()
def _eq_edit(self):
row_data = self._selected_eq_data()
if not row_data:
QMessageBox.warning(self, "Выбор", "Выберите снаряжение для редактирования")
return
dlg = EquipmentDialog(eq_id=row_data[0], parent=self)
if dlg.exec() == QDialog.DialogCode.Accepted:
self._load_equipment()
def _eq_delete(self):
row_data = self._selected_eq_data()
if not row_data:
QMessageBox.warning(self, "Выбор", "Выберите снаряжение для удаления")
return
reply = QMessageBox.question(
self, "Подтверждение",
f"Удалить «{row_data[2]}»?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
result = db.delete_equipment(row_data[0])
if result:
self._load_equipment()
else:
QMessageBox.critical(self, "Ошибка",
"Не удалось удалить запись.")
def _eq_csv_export(self):
headers = [
self.eq_table.horizontalHeaderItem(i).text()
for i in range(self.eq_table.columnCount())
]
filename = "equipment_export.csv"
try:
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(headers)
for r in range(self.eq_table.rowCount()):
writer.writerow([
self.eq_table.item(r, c).text()
if self.eq_table.item(r, c) else ""
for c in range(self.eq_table.columnCount())
])
QMessageBox.information(self, "Экспорт",
f"Данные сохранены в {filename}")
except Exception as e:
QMessageBox.critical(self, "Ошибка экспорта", str(e))
# ── Заявки ────────────────────────────────────────────────
def _load_rents(self):
status = self.rent_status_filter.currentText()
# Клиент видит только свои заявки
if self._user.role.level < 50:
rows = db.get_user_rents(self._user.id)
else:
rows = db.get_all_rents(
status_filter=status if status != "Все" else None
)
self.rent_table.setRowCount(0)
for row_data in (rows or []):
r = self.rent_table.rowCount()
self.rent_table.insertRow(r)
for col, val in enumerate(row_data):
item = QTableWidgetItem(str(val) if val is not None else "")
item.setData(Qt.ItemDataRole.UserRole, row_data[0]) # ID
self.rent_table.setItem(r, col, item)
def _selected_rent_id(self) -> int | None:
row = self.rent_table.currentRow()
if row < 0:
QMessageBox.warning(self, "Выбор", "Выберите заявку")
return None
item = self.rent_table.item(row, 0)
return int(item.text()) if item else None
def _rent_create(self):
dlg = RentDialog(user=self._user, parent=self)
if dlg.exec() == QDialog.DialogCode.Accepted:
self._load_rents()
def _rent_set_status(self, status: str):
rent_id = self._selected_rent_id()
if rent_id is None:
return
reply = QMessageBox.question(
self, "Подтверждение",
f"Установить статус «{status}» для заявки #{rent_id}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
result = db.update_rent_status(rent_id, status)
if result:
self._load_rents()
else:
QMessageBox.critical(self, "Ошибка",
"Не удалось обновить статус.")
# ═══════════════════════════════════════════════════════════════
# EquipmentDialog добавление / редактирование снаряжения
# ═══════════════════════════════════════════════════════════════
class EquipmentDialog(QDialog):
def __init__(self, eq_id: int | None = None, parent=None):
super().__init__(parent)
self._eq_id = eq_id
self._img_path = ""
self.setWindowTitle(
"Редактировать снаряжение" if eq_id else "Добавить снаряжение"
)
self.setMinimumWidth(460)
self._build_ui()
self._load_lookups()
if eq_id:
self._load_data(eq_id)
def _build_ui(self):
layout = QVBoxLayout(self)
form = QFormLayout()
self._id_field = QLineEdit()
self._id_field.setReadOnly(True)
self._id_field.setPlaceholderText("Автоматически")
form.addRow("ID:", self._id_field)
self._inv = QLineEdit()
self._inv.setMaxLength(4)
form.addRow("Инв. номер *:", self._inv)
self._name = QLineEdit()
form.addRow("Наименование *:", self._name)
self._type_cb = QComboBox()
form.addRow("Тип *:", self._type_cb)
self._rent = QDoubleSpinBox()
self._rent.setRange(0, 99999)
self._rent.setDecimals(2)
self._rent.setSuffix("")
form.addRow("Аренда/сут *:", self._rent)
self._manuf_cb = QComboBox()
form.addRow("Производитель *:", self._manuf_cb)
self._point_cb = QComboBox()
form.addRow("Пункт выдачи *:", self._point_cb)
# Фото
img_row = QHBoxLayout()
self._img_preview = QLabel("Нет фото")
self._img_preview.setFixedSize(100, 100)
self._img_preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._img_preview.setStyleSheet(
"border:1px solid #aaa; background:#eee;"
)
btn_pick = QPushButton("Выбрать фото…")
btn_pick.clicked.connect(self._pick_image)
img_row.addWidget(self._img_preview)
img_row.addWidget(btn_pick)
form.addRow("Фото:", img_row)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(self._save)
btns.rejected.connect(self.reject)
layout.addWidget(btns)
def _load_lookups(self):
for tid, tname in (db.get_cmd_types() or []):
self._type_cb.addItem(tname, tid)
for mid, mname in (db.get_manufacturers() or []):
self._manuf_cb.addItem(mname, mid)
for pid, pname in (db.get_pickup_points() or []):
self._point_cb.addItem(pname, pid)
def _load_data(self, eq_id: int):
row = db.get_equipment_by_id(eq_id)
if not row:
return
# id, inv_number, supply_name, supply_type, rent,
# manufacturer, pick_up_point, img_path
self._id_field.setText(str(row[0]))
self._inv.setText(str(row[1]))
self._name.setText(str(row[2]))
self._set_combo(self._type_cb, row[3])
self._rent.setValue(float(row[4]))
self._set_combo(self._manuf_cb, row[5])
self._set_combo(self._point_cb, row[6])
self._img_path = row[7] or ""
self._refresh_preview()
def _set_combo(self, cb: QComboBox, value):
for i in range(cb.count()):
if cb.itemData(i) == value:
cb.setCurrentIndex(i)
return
def _pick_image(self):
path, _ = QFileDialog.getOpenFileName(
self, "Выбрать изображение", "",
"Изображения (*.png *.jpg *.jpeg *.bmp)"
)
if path:
self._img_path = os.path.relpath(path)
self._refresh_preview()
def _refresh_preview(self):
full = os.path.abspath(self._img_path) if self._img_path else ""
if self._img_path and os.path.exists(full):
px = QPixmap(full).scaled(
100, 100, Qt.AspectRatioMode.KeepAspectRatio
)
self._img_preview.setPixmap(px)
else:
self._img_preview.setText("Нет фото")
def _save(self):
inv = self._inv.text().strip()
name = self._name.text().strip()
ttype = self._type_cb.currentData()
rent = self._rent.value()
manuf = self._manuf_cb.currentData()
point = self._point_cb.currentData()
img = self._img_path
if not inv or not name:
QMessageBox.warning(self, "Ошибка",
"Инв. номер и наименование обязательны.")
return
if self._eq_id:
result = db.update_equipment(
self._eq_id, inv, name, ttype, rent, manuf, point, img
)
else:
result = db.insert_equipment(
inv, name, ttype, rent, manuf, point, img
)
if result:
self.accept()
else:
QMessageBox.critical(self, "Ошибка БД",
"Не удалось сохранить запись.")
# ═══════════════════════════════════════════════════════════════
# RentDialog создание заявки на аренду
# ═══════════════════════════════════════════════════════════════
class RentDialog(QDialog):
def __init__(self, user: User, parent=None):
super().__init__(parent)
self._user = user
self.setWindowTitle("Новая заявка на аренду")
self.setMinimumWidth(400)
self._build_ui()
self._load_data()
def _build_ui(self):
layout = QVBoxLayout(self)
box = QGroupBox("Параметры аренды")
form = QFormLayout(box)
self._eq_cb = QComboBox()
form.addRow("Снаряжение *:", self._eq_cb)
# Для сотрудника и выше — выбор клиента
# Для клиента — только своё имя (нередактируемо)
self._client_cb = QComboBox()
form.addRow("Клиент *:", self._client_cb)
self._date_from = QDateEdit(QDate.currentDate())
self._date_from.setCalendarPopup(True)
self._date_from.setDisplayFormat("dd.MM.yyyy")
form.addRow("Начало аренды *:", self._date_from)
self._date_to = QDateEdit(QDate.currentDate().addDays(1))
self._date_to.setCalendarPopup(True)
self._date_to.setDisplayFormat("dd.MM.yyyy")
form.addRow("Конец аренды *:", self._date_to)
layout.addWidget(box)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(self._save)
btns.rejected.connect(self.reject)
layout.addWidget(btns)
def _load_data(self):
# Только доступное снаряжение
equipment = db.get_equipment()
for row in (equipment or []):
if row[8]: # is_available
self._eq_cb.addItem(f"[{row[1]}] {row[2]}", row[0])
# Список клиентов
clients = db.get_clients()
if self._user.role.level >= 50:
# Сотрудник видит всех клиентов
for cid, cname in (clients or []):
self._client_cb.addItem(cname, cid)
else:
# Клиент видит только себя — ищем совпадение по имени в users
conn = None
try:
import psycopg2
from .db import DB_CONFIG
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute(
"""
SELECT cl.id, cl.name FROM client cl
JOIN users u ON u.name = cl.name
WHERE u.id = %s
""",
(self._user.id,)
)
row = cur.fetchone()
if row:
self._client_cb.addItem(row[1], row[0])
self._client_cb.setEnabled(False)
else:
self._client_cb.addItem(self._user.name, None)
self._client_cb.setEnabled(False)
except Exception:
self._client_cb.addItem(self._user.name, None)
self._client_cb.setEnabled(False)
finally:
if conn:
conn.close()
def _save(self):
eq_id = self._eq_cb.currentData()
client_id = self._client_cb.currentData()
qd_from = self._date_from.date()
qd_to = self._date_to.date()
date_from = f"{qd_from.year()}-{qd_from.month():02d}-{qd_from.day():02d}"
date_to = f"{qd_to.year()}-{qd_to.month():02d}-{qd_to.day():02d}"
if not eq_id:
QMessageBox.warning(self, "Ошибка", "Нет доступного снаряжения.")
return
if not client_id:
QMessageBox.warning(self, "Ошибка",
"Не удалось определить клиента.")
return
if self._date_to.date() <= self._date_from.date():
QMessageBox.warning(self, "Ошибка",
"Дата окончания должна быть позже даты начала.")
return
# Привязываем сотрудника если пользователь — сотрудник
employee_id = None
if self._user.role.level >= 50 and self._user.id > 0:
try:
import psycopg2
from .db import DB_CONFIG
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute(
"SELECT id FROM employee WHERE name = "
"(SELECT name FROM users WHERE id = %s)",
(self._user.id,)
)
row = cur.fetchone()
if row:
employee_id = row[0]
conn.close()
except Exception:
pass
result = db.create_rent(
commodity_id=eq_id,
client_id=client_id,
r_start=date_from,
r_end=date_to,
employee_id=employee_id
)
if result:
self.accept()
else:
QMessageBox.critical(self, "Ошибка БД",
"Не удалось создать заявку.")