| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138 |
- """
- Театр — Приложение для управления базой данных
- PyQt6 + PostgreSQL (psycopg2)
- Python 3.10+
- """
- import sys
- import csv
- import hashlib
- import os
- from datetime import datetime
- from typing import Optional
- import psycopg2
- import psycopg2.extras
- from PyQt6.QtWidgets import (
- QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QTabWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel,
- QLineEdit, QComboBox, QDateTimeEdit, QDoubleSpinBox, QSpinBox,
- QDialog, QFormLayout, QDialogButtonBox, QMessageBox, QFileDialog,
- QGroupBox, QTextEdit, QSplitter, QHeaderView, QFrame, QStatusBar,
- QInputDialog, QCheckBox, QListWidget
- )
- from PyQt6.QtCore import Qt, QDateTime, QThread, pyqtSignal, QTimer
- from PyQt6.QtGui import QColor, QFont, QIcon, QPalette
- # ── Настройки подключения ──────────────────────────────────────────────────
- DB_CONFIG = {
- "host": os.environ.get("DB_HOST", "edu-pg.itiscaf.ru"),
- "port": int(os.environ.get("DB_PORT", 5432)),
- "dbname": os.environ.get("DB_NAME", "dbwork"),
- "user": os.environ.get("DB_USER", "sokolov_ta"),
- "password": os.environ.get("DB_PASS", "%4aH&Dz6a5#"),
- "connect_timeout": 5,
- "options": "-c search_path=sokolov_ta,public"
- }
- APP_ROLES = {
- "admin": "Администратор",
- "cashier": "Кассир",
- "buyer": "Покупатель",
- }
- BLUE = "#2d6eaa"
- BLUE_DARK = "#1a4a7a"
- LIGHT_BG = "#f4f6f9"
- ACCENT = "#e8f4fd"
- STYLE = f"""
- QMainWindow, QDialog, QWidget {{ background: {LIGHT_BG}; color: #222; }}
- QTabWidget::pane {{ border: 1px solid #c8d6e5; background: white; border-radius: 4px; }}
- QTabBar::tab {{ background: #dce8f5; color: #333; padding: 8px 18px; border-radius: 4px 4px 0 0; margin-right: 2px; }}
- QTabBar::tab:selected {{ background: {BLUE}; color: white; }}
- QPushButton {{
- background: {BLUE}; color: white; border: none;
- padding: 7px 18px; border-radius: 5px; font-weight: bold;
- }}
- QPushButton:hover {{ background: {BLUE_DARK}; }}
- QPushButton:pressed {{ background: #0f3358; }}
- QPushButton:disabled {{ background: #aabdd4; color: #ddd; }}
- QPushButton#danger {{ background: #c0392b; color: white; }}
- QPushButton#danger:hover {{ background: #a93226; }}
- QPushButton#success {{ background: #27ae60; color: white; }}
- QPushButton#success:hover {{ background: #1e8449; }}
- QPushButton#secondary {{ background: #6c8eaf; color: white; }}
- QPushButton#secondary:hover {{ background: #526a87; }}
- QLineEdit, QComboBox, QDateTimeEdit, QDoubleSpinBox, QSpinBox {{
- border: 1px solid #b0c4d8; border-radius: 4px;
- padding: 5px 8px; background: white; color: #222;
- }}
- QLineEdit:focus, QComboBox:focus {{ border-color: {BLUE}; }}
- QComboBox QAbstractItemView {{ background: white; color: #222; selection-background-color: {ACCENT}; selection-color: #1a4a7a; }}
- QTableWidget {{ border: 1px solid #c8d6e5; gridline-color: #e0e8f0; background: white; color: #222; }}
- QTableWidget::item {{ color: #222; }}
- QTableWidget::item:selected {{ background: {ACCENT}; color: #1a4a7a; }}
- QHeaderView::section {{
- background: {BLUE}; color: white;
- padding: 6px; border: none; font-weight: bold;
- }}
- QGroupBox {{ border: 1px solid #c8d6e5; border-radius: 6px; margin-top: 10px; padding-top: 8px; color: #222; }}
- QGroupBox::title {{ subcontrol-origin: margin; left: 10px; color: {BLUE}; font-weight: bold; }}
- QLabel {{ color: #222; }}
- QLabel#header {{ font-size: 18px; font-weight: bold; color: {BLUE_DARK}; }}
- QLabel#hint {{ color: #666; font-size: 11px; }}
- QStatusBar {{ background: {BLUE_DARK}; color: white; padding: 2px 8px; }}
- QDialogButtonBox QPushButton {{ min-width: 80px; }}
- QListWidget {{ background: white; color: #222; border: 1px solid #b0c4d8; border-radius: 4px; }}
- QListWidget::item:selected {{ background: {ACCENT}; color: #1a4a7a; }}
- """
- # ══════════════════════════════════════════════════════════════════════════════
- # DATABASE LAYER
- # ══════════════════════════════════════════════════════════════════════════════
- class Database:
- def __init__(self):
- self.conn: Optional[psycopg2.extensions.connection] = None
- def connect(self, config: dict) -> bool:
- try:
- self.conn = psycopg2.connect(**config)
- self.conn.autocommit = False
- return True
- except Exception as e:
- print(f"DB connect error: {e}")
- return False
- def disconnect(self):
- if self.conn:
- self.conn.close()
- self.conn = None
- def execute(self, sql: str, params=None) -> list:
- """Выполнить запрос и вернуть строки."""
- with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
- cur.execute(sql, params)
- self.conn.commit()
- try:
- return cur.fetchall()
- except psycopg2.ProgrammingError:
- return []
- def execute_many(self, sql: str, data: list):
- with self.conn.cursor() as cur:
- psycopg2.extras.execute_batch(cur, sql, data)
- self.conn.commit()
- def verify_password(self, email: str, password: str):
- rows = self.execute(
- """
- SELECT user_id, last_name, name, mid_name, role, is_active
- FROM users
- WHERE email = %s AND password_hash = %s
- """,
- (email, password)
- )
- if rows and rows[0]["is_active"]:
- return dict(rows[0])
- return None
- # ── TICKETS ──
- def get_tickets(self, search: str = "", status: str = "", show_id: int = 0) -> list:
- sql = """
- SELECT t.ticket_id, s.title AS show_title, t.event_datetime,
- h.name AS hall, se.row_number, se.seat_number, se.category,
- t.price, t.status, a.manager_name
- FROM tickets t
- JOIN shows s ON t.show_id = s.show_id
- JOIN seats se ON t.seat_id = se.seat_id
- JOIN halls h ON se.hall_id = h.hall_id
- JOIN accounting a ON t.accounting_id = a.accounting_id
- WHERE 1=1
- """
- params = []
- if search:
- sql += " AND (s.title ILIKE %s OR se.category ILIKE %s)"
- params += [f"%{search}%", f"%{search}%"]
- if status:
- sql += " AND t.status = %s"
- params.append(status)
- if show_id:
- sql += " AND t.show_id = %s"
- params.append(show_id)
- sql += " ORDER BY t.event_datetime DESC, t.ticket_id"
- return self.execute(sql, params or None)
- def add_ticket(self, data: dict) -> bool:
- try:
- self.execute(
- """INSERT INTO tickets (accounting_id, show_id, seat_id, event_datetime, price, status)
- VALUES (%(accounting_id)s, %(show_id)s, %(seat_id)s,
- %(event_datetime)s, %(price)s, %(status)s)""",
- data
- )
- return True
- except Exception as e:
- self.conn.rollback()
- raise e
- def update_ticket(self, ticket_id: int, data: dict) -> bool:
- try:
- self.execute(
- """UPDATE tickets SET show_id=%(show_id)s, seat_id=%(seat_id)s,
- event_datetime=%(event_datetime)s, price=%(price)s, status=%(status)s
- WHERE ticket_id=%(ticket_id)s""",
- {**data, "ticket_id": ticket_id}
- )
- return True
- except Exception as e:
- self.conn.rollback()
- raise e
- def delete_ticket(self, ticket_id: int):
- # Нельзя удалить проданный билет
- rows = self.execute("SELECT status FROM tickets WHERE ticket_id=%s", (ticket_id,))
- if rows and rows[0]["status"] == "продан":
- raise ValueError("Нельзя удалить проданный билет!")
- self.execute("DELETE FROM tickets WHERE ticket_id=%s", (ticket_id,))
- # ── SALES ──
- def get_sales(self, search: str = "") -> list:
- sql = """
- SELECT sa.sale_id, u.last_name || ' ' || u.name || ' ' || u.mid_name AS buyer, u.email,
- sh.title AS show_title, t.event_datetime,
- t.price, sa.amount_paid, sa.payment_method, sa.sale_datetime
- FROM sales sa
- JOIN users u ON sa.user_id = u.user_id
- JOIN tickets t ON sa.ticket_id = t.ticket_id
- JOIN shows sh ON t.show_id = sh.show_id
- """
- params = []
- if search:
- sql += """WHERE
- last_name ILIKE %s OR name ILIKE %s OR mid_name ILIKE %s OR email ILIKE %s"""
- params = [ f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%"]
- sql += " ORDER BY sa.sale_datetime DESC"
- return self.execute(sql, params or None)
- def sell_ticket(self, ticket_id: int, user_id: int, method: str) -> bool:
- rows = self.execute("SELECT price, status FROM tickets WHERE ticket_id=%s", (ticket_id,))
- if not rows:
- raise ValueError("Билет не найден")
- t = rows[0]
- if t["status"] != "доступен":
- raise ValueError(f"Билет недоступен (статус: {t['status']})")
- try:
- self.execute(
- "UPDATE tickets SET status='продан' WHERE ticket_id=%s", (ticket_id,)
- )
- self.execute(
- """INSERT INTO sales (ticket_id, user_id, payment_method, amount_paid)
- VALUES (%s, %s, %s, %s)""",
- (ticket_id, user_id, method, t["price"])
- )
- return True
- except Exception as e:
- self.conn.rollback()
- raise e
- # ── USERS ──
- def get_users(self, search: str = "") -> list:
- sql = """SELECT user_id, last_name, name, mid_name, birthdate, phone, email, role, is_active, created_at
- FROM users"""
- params = []
- if search:
- sql += " WHERE last_name || ' ' || name || ' ' || mid_name ILIKE %s OR email ILIKE %s"
- params = [f"%{search}%", f"%{search}%"]
- sql += " ORDER BY last_name, name, mid_name"
- return self.execute(sql, params or None)
- def add_user(self, data: dict) -> bool:
- try:
- self.execute(
- """INSERT INTO users (last_name, name, mid_name, birthdate, phone, email, password_hash, role)
- VALUES (%(last_name)s, %(name)s, %(mid_name)s, %(birthdate)s, %(phone)s, %(email)s,
- %(password)s, %(role)s)""",
- data
- )
- return True
- except Exception as e:
- self.conn.rollback()
- raise e
- def toggle_user_active(self, user_id: int, active: bool):
- self.execute("UPDATE users SET is_active=%s WHERE user_id=%s", (active, user_id))
- # ── SHOWS ──
- def get_shows(self) -> list:
- return self.execute("SELECT * FROM shows ORDER BY title")
- # ── LOOKUPS ──
- def get_accounting_list(self) -> list:
- return self.execute("SELECT accounting_id, manager_name FROM accounting ORDER BY manager_name")
- def get_seats_by_hall(self, hall_id: int) -> list:
- return self.execute(
- "SELECT seat_id, row_number, seat_number, category FROM seats WHERE hall_id=%s ORDER BY row_number, seat_number",
- (hall_id,)
- )
- def get_halls(self) -> list:
- return self.execute("SELECT * FROM halls ORDER BY name")
- # ── REPORTS ──
- def revenue_by_show(self) -> list:
- return self.execute("""
- SELECT sh.title, COUNT(*) AS sold,
- SUM(sa.amount_paid) AS revenue
- FROM sales sa
- JOIN tickets t ON sa.ticket_id = t.ticket_id
- JOIN shows sh ON t.show_id = sh.show_id
- GROUP BY sh.title ORDER BY revenue DESC
- """)
- def get_audit_log(self, limit=50) -> list:
- return self.execute(
- "SELECT * FROM audit_log ORDER BY changed_at DESC LIMIT %s", (limit,)
- )
- # ══════════════════════════════════════════════════════════════════════════════
- # DIALOGS
- # ══════════════════════════════════════════════════════════════════════════════
- class LoginDialog(QDialog):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Вход в систему — Театр")
- self.setFixedWidth(380)
- self.user_data = None
- layout = QVBoxLayout(self)
- layout.setSpacing(14)
- title = QLabel("🎭 Театр — Вход в систему")
- title.setObjectName("header")
- title.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title)
- form = QFormLayout()
- self.email_edit = QLineEdit()
- self.email_edit.setPlaceholderText("example@mail.ru")
- self.pass_edit = QLineEdit()
- self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
- form.addRow("Email:", self.email_edit)
- form.addRow("Пароль:", self.pass_edit)
- layout.addLayout(form)
- hint = QLabel("<i>Демо: admin@teatr.ru / AdminPass!1</i>")
- hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
- hint.setStyleSheet("color: gray; font-size: 11px;")
- layout.addWidget(hint)
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- btns.accepted.connect(self.try_login)
- btns.rejected.connect(self.reject)
- layout.addWidget(btns)
- self.db = Database()
- def set_db_config(self, config: dict):
- self.db.connect(config)
- def try_login(self):
- email = self.email_edit.text().strip()
- password = self.pass_edit.text()
- if not email or not password:
- QMessageBox.warning(self, "Ошибка", "Заполните все поля.")
- return
- user = self.db.verify_password(email, password)
- if user:
- self.user_data = user
- self.accept()
- else:
- QMessageBox.critical(self, "Ошибка", "Неверный email или пароль.")
- class TicketDialog(QDialog):
- """Диалог добавления / редактирования билета."""
- def __init__(self, db: Database, ticket=None, parent=None):
- super().__init__(parent)
- self.db = db
- self.ticket = ticket
- self.setWindowTitle("Редактировать билет" if ticket else "Добавить билет")
- self.setMinimumWidth(420)
- layout = QFormLayout(self)
- # Спектакль
- self.show_cb = QComboBox()
- shows = db.get_shows()
- for s in shows:
- self.show_cb.addItem(s["title"], s["show_id"])
- layout.addRow("Спектакль:", self.show_cb)
- # Касса
- self.acc_cb = QComboBox()
- accs = db.get_accounting_list()
- for a in accs:
- self.acc_cb.addItem(a["manager_name"], a["accounting_id"])
- layout.addRow("Кассир:", self.acc_cb)
- # Зал → место
- self.hall_cb = QComboBox()
- halls = db.get_halls()
- for h in halls:
- self.hall_cb.addItem(h["name"], h["hall_id"])
- self.hall_cb.currentIndexChanged.connect(self._load_seats)
- layout.addRow("Зал:", self.hall_cb)
- self.seat_cb = QComboBox()
- layout.addRow("Место:", self.seat_cb)
- self._load_seats()
- # Дата и время
- self.dt_edit = QDateTimeEdit(QDateTime.currentDateTime())
- self.dt_edit.setDisplayFormat("dd.MM.yyyy HH:mm")
- layout.addRow("Дата/время:", self.dt_edit)
- # Цена
- self.price_spin = QDoubleSpinBox()
- self.price_spin.setRange(0, 99999)
- self.price_spin.setSuffix(" ₽")
- layout.addRow("Цена:", self.price_spin)
- # Статус
- self.status_cb = QComboBox()
- self.status_cb.addItems(["доступен", "бронь", "продан", "возврат"])
- layout.addRow("Статус:", self.status_cb)
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
- btns.accepted.connect(self.accept)
- btns.rejected.connect(self.reject)
- layout.addRow(btns)
- if ticket:
- self._fill(ticket)
- def _load_seats(self):
- hall_id = self.hall_cb.currentData()
- self.seat_cb.clear()
- if hall_id:
- for s in self.db.get_seats_by_hall(hall_id):
- label = f"Ряд {s['row_number']}, место {s['seat_number']} ({s['category']})"
- self.seat_cb.addItem(label, s["seat_id"])
- def _fill(self, t):
- idx = self.show_cb.findData(t["show_id"]) if "show_id" in t else -1
- if idx >= 0:
- self.show_cb.setCurrentIndex(idx)
- self.price_spin.setValue(float(t.get("price", 0)))
- status_idx = self.status_cb.findText(t.get("status", "доступен"))
- if status_idx >= 0:
- self.status_cb.setCurrentIndex(status_idx)
- def get_data(self) -> dict:
- return {
- "accounting_id": self.acc_cb.currentData(),
- "show_id": self.show_cb.currentData(),
- "seat_id": self.seat_cb.currentData(),
- "event_datetime": self.dt_edit.dateTime().toString("yyyy-MM-dd HH:mm"),
- "price": self.price_spin.value(),
- "status": self.status_cb.currentText(),
- }
- class SellDialog(QDialog):
- """Диалог продажи билета."""
- def __init__(self, db: Database, ticket_id: int, parent=None):
- super().__init__(parent)
- self.db = db
- self.ticket_id = ticket_id
- self.setWindowTitle("Продажа билета")
- self.setFixedWidth(380)
- layout = QFormLayout(self)
- self.user_cb = QComboBox()
- users = db.get_users()
- fio = f"{u['last_name']} {u['name']} {u['mid_name'] or ''}".strip()
- for u in users:
- self.user_cb.addItem(f"{u['fio']} ({u['email']})", u["user_id"])
- layout.addRow("Покупатель:", self.user_cb)
- self.method_cb = QComboBox()
- self.method_cb.addItems(["карта", "наличные", "онлайн"])
- layout.addRow("Способ оплаты:", self.method_cb)
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- btns.accepted.connect(self.accept)
- btns.rejected.connect(self.reject)
- layout.addRow(btns)
- class UserDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Добавить пользователя")
- self.setFixedWidth(400)
- layout = QFormLayout(self)
- self.last_name_edit = QLineEdit()
- self.name_edit = QLineEdit()
- self.mid_name_edit = QLineEdit()
- self.email_edit = QLineEdit()
- self.phone_edit = QLineEdit()
- self.birth_edit = QLineEdit()
- self.birth_edit.setPlaceholderText("ГГГГ-ММ-ДД")
- self.pass_edit = QLineEdit()
- self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
- self.role_cb = QComboBox()
- self.role_cb.addItems(["buyer", "cashier", "admin"])
- layout.addRow("Фамилия:", self.last_name_edit)
- layout.addRow("Имя:", self.name_edit)
- layout.addRow("Отчество:", self.mid_name_edit)
- layout.addRow("Email:", self.email_edit)
- layout.addRow("Телефон:", self.phone_edit)
- layout.addRow("Дата рождения:", self.birth_edit)
- layout.addRow("Пароль:", self.pass_edit)
- layout.addRow("Роль:", self.role_cb)
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
- btns.accepted.connect(self.accept)
- btns.rejected.connect(self.reject)
- layout.addRow(btns)
- def get_data(self) -> dict:
- return {
- "last_name": self.last_name_edit.text().strip(),
- "name": self.name_edit.text().strip(),
- "mid_name": self.mid_name_edit.text().strip(),
- "email": self.email_edit.text().strip(),
- "phone": self.phone_edit.text().strip(),
- "birthdate": self.birth_edit.text().strip() or None,
- "password": self.pass_edit.text(),
- "role": self.role_cb.currentText(),
- }
- def make_table(headers: list) -> QTableWidget:
- t = QTableWidget(0, len(headers))
- t.setHorizontalHeaderLabels(headers)
- t.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
- t.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
- t.horizontalHeader().setStretchLastSection(True)
- t.verticalHeader().setVisible(False)
- t.setAlternatingRowColors(False)
- return t
- def fill_table(table: QTableWidget, rows: list, keys: list):
- table.setRowCount(0)
- for row in rows:
- r = table.rowCount()
- table.insertRow(r)
- for c, k in enumerate(keys):
- val = row.get(k, "")
- item = QTableWidgetItem(str(val) if val is not None else "")
- item.setData(Qt.ItemDataRole.UserRole, row)
- table.setItem(r, c, item)
- def export_to_csv(table: QTableWidget, parent=None):
- path, _ = QFileDialog.getSaveFileName(parent, "Сохранить CSV", "", "CSV (*.csv)")
- if not path:
- return
- with open(path, "w", newline="", encoding="utf-8-sig") as f:
- writer = csv.writer(f, delimiter=";")
- headers = [table.horizontalHeaderItem(i).text()
- for i in range(table.columnCount())]
- writer.writerow(headers)
- for row in range(table.rowCount()):
- writer.writerow([
- table.item(row, col).text() if table.item(row, col) else ""
- for col in range(table.columnCount())
- ])
- QMessageBox.information(parent, "Экспорт", f"Файл сохранён:\n{path}")
- # ── Вкладка: Билеты ──────────────────────────────────────────────────────────
- class TicketsTab(QWidget):
- def __init__(self, db: Database, user: dict):
- super().__init__()
- self.db = db
- self.user = user
- self._build_ui()
- self.refresh()
- def _build_ui(self):
- layout = QVBoxLayout(self)
- # Панель поиска
- search_grp = QGroupBox("Поиск и фильтрация")
- sg = QHBoxLayout(search_grp)
- self.search_edit = QLineEdit()
- self.search_edit.setPlaceholderText("Название спектакля, категория...")
- self.status_filter = QComboBox()
- self.status_filter.addItems(["", "доступен", "продан", "бронь", "возврат"])
- sg.addWidget(QLabel("Поиск:"))
- sg.addWidget(self.search_edit)
- sg.addWidget(QLabel("Статус:"))
- sg.addWidget(self.status_filter)
- btn_search = QPushButton("🔍 Найти")
- btn_search.clicked.connect(self.refresh)
- sg.addWidget(btn_search)
- layout.addWidget(search_grp)
- # Таблица
- self.table = make_table([
- "ID", "Спектакль", "Дата/время", "Зал",
- "Ряд", "Место", "Категория", "Цена ₽", "Статус"
- ])
- layout.addWidget(self.table)
- # Кнопки
- btn_row = QHBoxLayout()
- btn_add = QPushButton("➕ Добавить")
- btn_edit = QPushButton("✏️ Изменить")
- btn_del = QPushButton("🗑️ Удалить")
- btn_sell = QPushButton("💳 Продать")
- btn_exp = QPushButton("📤 Экспорт CSV")
- btn_ref = QPushButton("🔄 Обновить")
- btn_del.setObjectName("danger")
- btn_sell.setObjectName("success")
- btn_add.clicked.connect(self.add_ticket)
- btn_edit.clicked.connect(self.edit_ticket)
- btn_del.clicked.connect(self.delete_ticket)
- btn_sell.clicked.connect(self.sell_ticket)
- btn_exp.clicked.connect(lambda: export_to_csv(self.table, self))
- btn_ref.clicked.connect(self.refresh)
- for b in [btn_add, btn_edit, btn_del, btn_sell, btn_exp, btn_ref]:
- btn_row.addWidget(b)
- # Права доступа — скрываем кнопки управления для покупателя
- if self.user["role"] not in ("admin", "cashier"):
- for b in (btn_add, btn_edit, btn_del, btn_sell):
- b.setVisible(False)
- layout.addLayout(btn_row)
- def refresh(self):
- rows = self.db.get_tickets(
- self.search_edit.text(),
- self.status_filter.currentText()
- )
- keys = ["ticket_id","show_title","event_datetime","hall",
- "row_number","seat_number","category","price","status"]
- fill_table(self.table, rows, keys)
- def _selected_id(self) -> Optional[int]:
- row = self.table.currentRow()
- if row < 0:
- QMessageBox.warning(self, "Выбор", "Выберите строку.")
- return None
- item = self.table.item(row, 0)
- return int(item.text())
- def add_ticket(self):
- dlg = TicketDialog(self.db, parent=self)
- if dlg.exec():
- try:
- self.db.add_ticket(dlg.get_data())
- self.refresh()
- except Exception as e:
- QMessageBox.critical(self, "Ошибка", str(e))
- def edit_ticket(self):
- tid = self._selected_id()
- if tid is None:
- return
- row = self.table.currentRow()
- ticket = {"show_id": None, "price": float(self.table.item(row, 7).text()),
- "status": self.table.item(row, 8).text()}
- dlg = TicketDialog(self.db, ticket, parent=self)
- if dlg.exec():
- try:
- self.db.update_ticket(tid, dlg.get_data())
- self.refresh()
- except Exception as e:
- QMessageBox.critical(self, "Ошибка", str(e))
- def delete_ticket(self):
- tid = self._selected_id()
- if tid is None:
- return
- if QMessageBox.question(self, "Удаление", f"Удалить билет #{tid}?") == QMessageBox.StandardButton.Yes:
- try:
- self.db.delete_ticket(tid)
- self.refresh()
- except Exception as e:
- QMessageBox.critical(self, "Ошибка", str(e))
- def sell_ticket(self):
- tid = self._selected_id()
- if tid is None:
- return
- dlg = SellDialog(self.db, tid, parent=self)
- if dlg.exec():
- try:
- self.db.sell_ticket(
- tid,
- dlg.user_cb.currentData(),
- dlg.method_cb.currentText()
- )
- QMessageBox.information(self, "Успех", "Билет продан!")
- self.refresh()
- except Exception as e:
- QMessageBox.critical(self, "Ошибка", str(e))
- # ── Вкладка: Продажи ─────────────────────────────────────────────────────────
- class SalesTab(QWidget):
- def __init__(self, db: Database, user: dict):
- super().__init__()
- self.db = db
- self.user = user
- self._build_ui()
- self.refresh()
- def _build_ui(self):
- layout = QVBoxLayout(self)
- sh = QHBoxLayout()
- self.search_edit = QLineEdit()
- self.search_edit.setPlaceholderText("Имя покупателя, спектакль, email...")
- btn_s = QPushButton("🔍 Найти")
- btn_s.clicked.connect(self.refresh)
- btn_e = QPushButton("📤 Экспорт CSV")
- btn_e.clicked.connect(lambda: export_to_csv(self.table, self))
- sh.addWidget(QLabel("Поиск:"))
- sh.addWidget(self.search_edit)
- sh.addWidget(btn_s)
- sh.addWidget(btn_e)
- layout.addLayout(sh)
- self.table = make_table([
- "ID продажи", "Покупатель", "Email", "Спектакль",
- "Дата показа", "Цена ₽", "Оплачено ₽", "Способ", "Дата продажи"
- ])
- layout.addWidget(self.table)
- # Итог
- self.total_lbl = QLabel()
- self.total_lbl.setStyleSheet(f"color: {BLUE_DARK}; font-weight: bold; padding: 4px;")
- layout.addWidget(self.total_lbl)
- def refresh(self):
- rows = self.db.get_sales(self.search_edit.text())
- keys = ["sale_id","buyer","email","show_title",
- "event_datetime","price","amount_paid","payment_method","sale_datetime"]
- fill_table(self.table, rows, keys)
- total = sum(float(r["amount_paid"] or 0) for r in rows)
- self.total_lbl.setText(f"Итого: {total:,.2f} ₽ | Записей: {len(rows)}")
- # ── Вкладка: Пользователи ────────────────────────────────────────────────────
- class UsersTab(QWidget):
- def __init__(self, db: Database, user: dict):
- super().__init__()
- self.db = db
- self.user = user
- self._build_ui()
- self.refresh()
- def _build_ui(self):
- layout = QVBoxLayout(self)
- sh = QHBoxLayout()
- self.search_edit = QLineEdit()
- self.search_edit.setPlaceholderText("ФИО, email...")
- btn_s = QPushButton("🔍 Найти")
- btn_s.clicked.connect(self.refresh)
- sh.addWidget(QLabel("Поиск:"))
- sh.addWidget(self.search_edit)
- sh.addWidget(btn_s)
- layout.addLayout(sh)
- self.table = make_table([
- "ID", "Фaмилия", "Имя", "Отчество", "Дата рожд.", "Телефон", "Email", "Роль", "Активен", "Дата регистр."
- ])
- layout.addWidget(self.table)
- btn_row = QHBoxLayout()
- btn_add = QPushButton("➕ Добавить")
- btn_toggle = QPushButton("🔒 Активировать/Блок.")
- btn_exp = QPushButton("📤 Экспорт CSV")
- btn_ref = QPushButton("🔄 Обновить")
- btn_add.clicked.connect(self.add_user)
- btn_toggle.clicked.connect(self.toggle_user)
- btn_exp.clicked.connect(lambda: export_to_csv(self.table, self))
- btn_ref.clicked.connect(self.refresh)
- for b in [btn_add, btn_toggle, btn_exp, btn_ref]:
- btn_row.addWidget(b)
- if self.user["role"] != "admin":
- btn_add.setVisible(False)
- btn_toggle.setVisible(False)
- layout.addLayout(btn_row)
- def refresh(self):
- rows = self.db.get_users(self.search_edit.text())
- keys = [
- "user_id",
- "last_name",
- "name",
- "mid_name",
- "birthdate",
- "phone",
- "email",
- "role",
- "is_active",
- "created_at"
- ]
- fill_table(self.table, rows, keys)
- def add_user(self):
- dlg = UserDialog(self)
- if dlg.exec():
- try:
- self.db.add_user(dlg.get_data())
- self.refresh()
- except Exception as e:
- QMessageBox.critical(self, "Ошибка", str(e))
- def toggle_user(self):
- row = self.table.currentRow()
- if row < 0:
- QMessageBox.warning(self, "Выбор", "Выберите пользователя.")
- return
- uid = int(self.table.item(row, 0).text())
- active = self.table.item(row, 6).text() == "True"
- self.db.toggle_user_active(uid, not active)
- self.refresh()
- # ── Вкладка: Отчёты ──────────────────────────────────────────────────────────
- class ReportsTab(QWidget):
- def __init__(self, db: Database):
- super().__init__()
- self.db = db
- self._build_ui()
- def _build_ui(self):
- layout = QVBoxLayout(self)
- btn_rev = QPushButton("📊 Выручка по спектаклям")
- btn_rev.clicked.connect(self.show_revenue)
- btn_aud = QPushButton("📋 Журнал аудита")
- btn_aud.clicked.connect(self.show_audit)
- row = QHBoxLayout()
- row.addWidget(btn_rev)
- row.addWidget(btn_aud)
- row.addStretch()
- layout.addLayout(row)
- self.report_table = make_table(["Спектакль", "Продано билетов", "Выручка ₽"])
- layout.addWidget(self.report_table)
- self.summary_lbl = QLabel()
- self.summary_lbl.setStyleSheet(f"color: {BLUE_DARK}; font-weight: bold;")
- layout.addWidget(self.summary_lbl)
- btn_exp = QPushButton("📤 Экспорт CSV")
- btn_exp.clicked.connect(lambda: export_to_csv(self.report_table, self))
- layout.addWidget(btn_exp)
- def show_revenue(self):
- rows = self.db.revenue_by_show()
- self.report_table.setColumnCount(3)
- self.report_table.setHorizontalHeaderLabels(["Спектакль", "Продано", "Выручка ₽"])
- fill_table(self.report_table, rows, ["title", "sold", "revenue"])
- total = sum(float(r["revenue"] or 0) for r in rows)
- self.summary_lbl.setText(f"Общая выручка: {total:,.2f} ₽")
- def show_audit(self):
- rows = self.db.get_audit_log()
- self.report_table.setColumnCount(5)
- self.report_table.setHorizontalHeaderLabels(
- ["ID", "Таблица", "Операция", "Пользователь", "Дата/время"]
- )
- fill_table(self.report_table, rows,
- ["log_id","table_name","operation","changed_by","changed_at"])
- self.summary_lbl.setText(f"Записей в журнале: {len(rows)}")
- # ══════════════════════════════════════════════════════════════════════════════
- # MAIN WINDOW
- # ══════════════════════════════════════════════════════════════════════════════
- class MainWindow(QMainWindow):
- def __init__(self, db: Database, user: dict):
- super().__init__()
- self.db = db
- self.user = user
- fio = f"{user['last_name']} {user['name']}"
- if user['mid_name']:
- fio += f" {user['mid_name']}"
- self.setWindowTitle(f"Театр — {fio} ({APP_ROLES.get(user['role'], user['role'])})")
- self.resize(1100, 680)
- # Статус-бар
- sb = QStatusBar()
- self.setStatusBar(sb)
- sb.showMessage(f"Подключено | Пользователь: {fio} | Роль: {APP_ROLES.get(user['role'])}")
- # Вкладки
- tabs = QTabWidget()
- role = user["role"]
- # Вкладка «Билеты» — доступна всем
- tabs.addTab(TicketsTab(db, user), "🎫 Билеты")
- # Вкладка «Продажи» — только admin и cashier
- if role in ("admin", "cashier"):
- tabs.addTab(SalesTab(db, user), "💳 Продажи")
- # Вкладка «Пользователи» — только admin
- if role == "admin":
- tabs.addTab(UsersTab(db, user), "👤 Пользователи")
- # Вкладка «Отчёты» — только admin и cashier
- if role in ("admin", "cashier"):
- tabs.addTab(ReportsTab(db), "📊 Отчёты")
- self.setCentralWidget(tabs)
- # ══════════════════════════════════════════════════════════════════════════════
- # ENTRY POINT
- # ══════════════════════════════════════════════════════════════════════════════
- import json
- # ── Хранилище профилей подключения ──────────────────────────────────────────
- PROFILES_FILE = os.path.join(os.path.expanduser("~"), ".theatre_db_profiles.json")
- def load_profiles() -> list:
- """Загрузить сохранённые профили подключения."""
- try:
- if os.path.exists(PROFILES_FILE):
- with open(PROFILES_FILE, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception:
- pass
- return []
- def save_profiles(profiles: list):
- """Сохранить профили подключения."""
- try:
- with open(PROFILES_FILE, "w", encoding="utf-8") as f:
- json.dump(profiles, f, ensure_ascii=False, indent=2)
- except Exception as e:
- print(f"Ошибка сохранения профилей: {e}")
- class DBConnectDialog(QDialog):
- """
- Диалог выбора/создания профиля подключения к БД.
- Показывает список сохранённых пар (логин, пароль).
- Позволяет выбрать, удалить или добавить новый профиль.
- """
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Подключение к базе данных")
- self.setFixedWidth(440)
- self.selected_config: dict | None = None
- self._profiles = load_profiles()
- self._build_ui()
- def _build_ui(self):
- from PyQt6.QtWidgets import QListWidget, QListWidgetItem
- layout = QVBoxLayout(self)
- layout.setSpacing(12)
- title = QLabel("🎭 Театр — Подключение к БД")
- title.setObjectName("header")
- title.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title)
- # Группа сохранённых профилей
- saved_grp = QGroupBox("Сохранённые подключения")
- sg = QVBoxLayout(saved_grp)
- self.list_widget = QListWidget()
- self.list_widget.setMinimumHeight(120)
- self._populate_list()
- self.list_widget.itemDoubleClicked.connect(self._accept_selected)
- sg.addWidget(self.list_widget)
- btn_row = QHBoxLayout()
- self.btn_connect = QPushButton("✅ Подключиться")
- self.btn_connect.setObjectName("success")
- self.btn_connect.clicked.connect(self._accept_selected)
- btn_delete = QPushButton("🗑️ Удалить")
- btn_delete.setObjectName("danger")
- btn_delete.clicked.connect(self._delete_selected)
- btn_row.addWidget(self.btn_connect)
- btn_row.addWidget(btn_delete)
- sg.addLayout(btn_row)
- layout.addWidget(saved_grp)
- # Разделитель
- line = QFrame()
- line.setFrameShape(QFrame.Shape.HLine)
- line.setFrameShadow(QFrame.Shadow.Sunken)
- layout.addWidget(line)
- # Группа нового подключения
- new_grp = QGroupBox("Новое подключение")
- ng = QFormLayout(new_grp)
- self.user_edit = QLineEdit()
- self.user_edit.setPlaceholderText("Пользователь PostgreSQL")
- self.pass_edit = QLineEdit()
- self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
- self.pass_edit.setPlaceholderText("Пароль")
- ng.addRow("Логин:", self.user_edit)
- ng.addRow("Пароль:", self.pass_edit)
- btn_new = QPushButton("🔌 Подключиться с новым аккаунтом")
- btn_new.setObjectName("secondary")
- btn_new.clicked.connect(self._connect_new)
- ng.addRow(btn_new)
- layout.addWidget(new_grp)
- # Кнопка отмены
- btn_cancel = QPushButton("Отмена")
- btn_cancel.setObjectName("secondary")
- btn_cancel.clicked.connect(self.reject)
- layout.addWidget(btn_cancel)
- self._update_connect_btn_state()
- self.list_widget.currentRowChanged.connect(lambda _: self._update_connect_btn_state())
- def _populate_list(self):
- self.list_widget.clear()
- for p in self._profiles:
- self.list_widget.addItem(f"👤 {p['user']}")
- if self._profiles:
- self.list_widget.setCurrentRow(0)
- def _update_connect_btn_state(self):
- self.btn_connect.setEnabled(self.list_widget.currentRow() >= 0)
- def _build_config(self, user: str, password: str) -> dict:
- base = DB_CONFIG.copy()
- base["user"] = user
- base["password"] = password
- return base
- def _accept_selected(self):
- idx = self.list_widget.currentRow()
- if idx < 0:
- QMessageBox.warning(self, "Выбор", "Выберите профиль из списка.")
- return
- p = self._profiles[idx]
- config = self._build_config(p["user"], p["password"])
- db_test = Database()
- if not db_test.connect(config):
- QMessageBox.critical(self, "Ошибка подключения",
- f"Не удалось подключиться с логином «{p['user']}».\n"
- "Возможно, данные устарели — удалите профиль и добавьте заново.")
- return
- db_test.disconnect()
- self.selected_config = config
- self.accept()
- def _connect_new(self):
- user = self.user_edit.text().strip()
- password = self.pass_edit.text()
- if not user:
- QMessageBox.warning(self, "Ввод", "Введите логин.")
- return
- # Проверяем дубликат по логину
- existing_users = [p["user"] for p in self._profiles]
- if user in existing_users:
- QMessageBox.information(self, "Уже есть",
- f"Профиль с логином «{user}» уже сохранён.\nВыберите его из списка.")
- # Выбрать соответствующий элемент в списке
- idx = existing_users.index(user)
- self.list_widget.setCurrentRow(idx)
- return
- config = self._build_config(user, password)
- db_test = Database()
- if not db_test.connect(config):
- QMessageBox.critical(self, "Ошибка подключения",
- "Не удалось подключиться.\nПроверьте логин и пароль.")
- return
- db_test.disconnect()
- # Сохраняем новый профиль
- self._profiles.append({"user": user, "password": password})
- save_profiles(self._profiles)
- self._populate_list()
- self.list_widget.setCurrentRow(len(self._profiles) - 1)
- self.selected_config = config
- self.accept()
- def _delete_selected(self):
- idx = self.list_widget.currentRow()
- if idx < 0:
- return
- user = self._profiles[idx]["user"]
- reply = QMessageBox.question(
- self, "Удаление профиля",
- f"Удалить профиль «{user}»?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
- )
- if reply == QMessageBox.StandardButton.Yes:
- self._profiles.pop(idx)
- save_profiles(self._profiles)
- self._populate_list()
- self._update_connect_btn_state()
- def main():
- app = QApplication(sys.argv)
- app.setStyleSheet(STYLE)
- # Диалог выбора подключения к БД (только логин + пароль)
- conn_dlg = DBConnectDialog()
- if conn_dlg.exec() != QDialog.DialogCode.Accepted:
- sys.exit(0)
- config = conn_dlg.selected_config
- db = Database()
- if not db.connect(config):
- QMessageBox.critical(None, "Ошибка", "Не удалось подключиться к базе данных.")
- sys.exit(1)
- # Логин в приложение
- login = LoginDialog()
- login.set_db_config(config)
- if login.exec() != QDialog.DialogCode.Accepted:
- sys.exit(0)
- window = MainWindow(db, login.user_data)
- window.show()
- sys.exit(app.exec())
- if __name__ == "__main__":
- main()
|