commit 18d456bf2ebca50678257924710443811450285b Author: helldh Date: Fri Feb 27 23:18:40 2026 +0300 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fc95ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +*.pyc diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/master.iml b/.idea/master.iml new file mode 100644 index 0000000..460d402 --- /dev/null +++ b/.idea/master.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..23231ce --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..32163c3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/Велосипед Giant ATX.jpg b/assets/Велосипед Giant ATX.jpg new file mode 100644 index 0000000..9bacc7b Binary files /dev/null and b/assets/Велосипед Giant ATX.jpg differ diff --git a/assets/Велосипед Trek X200.jpg b/assets/Велосипед Trek X200.jpg new file mode 100644 index 0000000..93437c6 Binary files /dev/null and b/assets/Велосипед Trek X200.jpg differ diff --git a/assets/Лыжи SnowFast 300.jpeg b/assets/Лыжи SnowFast 300.jpeg new file mode 100644 index 0000000..b7714cf Binary files /dev/null and b/assets/Лыжи SnowFast 300.jpeg differ diff --git a/assets/Ролики SpeedRun.jpg b/assets/Ролики SpeedRun.jpg new file mode 100644 index 0000000..ee80fc7 Binary files /dev/null and b/assets/Ролики SpeedRun.jpg differ diff --git a/assets/Самокат Urban Pro.jpg b/assets/Самокат Urban Pro.jpg new file mode 100644 index 0000000..86fcc5e Binary files /dev/null and b/assets/Самокат Urban Pro.jpg differ diff --git a/assets/Сноуборд Arctic Pro.jpg b/assets/Сноуборд Arctic Pro.jpg new file mode 100644 index 0000000..d7b31d4 Binary files /dev/null and b/assets/Сноуборд Arctic Pro.jpg differ diff --git a/composer.py b/composer.py new file mode 100644 index 0000000..1383456 --- /dev/null +++ b/composer.py @@ -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()) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a7ddfea --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +from composer import Composer + +def main(): + composer = Composer() + composer.run() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/postgresql.sql b/postgresql.sql new file mode 100644 index 0000000..3a2ab38 --- /dev/null +++ b/postgresql.sql @@ -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); \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..ece5db5 --- /dev/null +++ b/src/db.py @@ -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 \ No newline at end of file diff --git a/src/objects.py b/src/objects.py new file mode 100644 index 0000000..6a40d52 --- /dev/null +++ b/src/objects.py @@ -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 \ No newline at end of file diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..f55d96f --- /dev/null +++ b/src/utils.py @@ -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) \ No newline at end of file diff --git a/src/windows.py b/src/windows.py new file mode 100644 index 0000000..acbc1a8 --- /dev/null +++ b/src/windows.py @@ -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, "Ошибка БД", + "Не удалось создать заявку.") \ No newline at end of file