theatre_app.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. """
  2. Театр — Приложение для управления базой данных
  3. PyQt6 + PostgreSQL (psycopg2)
  4. Python 3.10+
  5. """
  6. import sys
  7. import csv
  8. import hashlib
  9. import os
  10. from datetime import datetime
  11. from typing import Optional
  12. import psycopg2
  13. import psycopg2.extras
  14. from PyQt6.QtWidgets import (
  15. QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  16. QTabWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel,
  17. QLineEdit, QComboBox, QDateTimeEdit, QDoubleSpinBox, QSpinBox,
  18. QDialog, QFormLayout, QDialogButtonBox, QMessageBox, QFileDialog,
  19. QGroupBox, QTextEdit, QSplitter, QHeaderView, QFrame, QStatusBar,
  20. QInputDialog, QCheckBox, QListWidget
  21. )
  22. from PyQt6.QtCore import Qt, QDateTime, QThread, pyqtSignal, QTimer
  23. from PyQt6.QtGui import QColor, QFont, QIcon, QPalette
  24. # ── Настройки подключения ──────────────────────────────────────────────────
  25. DB_CONFIG = {
  26. "host": os.environ.get("DB_HOST", "edu-pg.itiscaf.ru"),
  27. "port": int(os.environ.get("DB_PORT", 5432)),
  28. "dbname": os.environ.get("DB_NAME", "dbwork"),
  29. "user": os.environ.get("DB_USER", "sokolov_ta"),
  30. "password": os.environ.get("DB_PASS", "%4aH&Dz6a5#"),
  31. "connect_timeout": 5,
  32. "options": "-c search_path=sokolov_ta,public"
  33. }
  34. APP_ROLES = {
  35. "admin": "Администратор",
  36. "cashier": "Кассир",
  37. "buyer": "Покупатель",
  38. }
  39. BLUE = "#2d6eaa"
  40. BLUE_DARK = "#1a4a7a"
  41. LIGHT_BG = "#f4f6f9"
  42. ACCENT = "#e8f4fd"
  43. STYLE = f"""
  44. QMainWindow, QDialog, QWidget {{ background: {LIGHT_BG}; color: #222; }}
  45. QTabWidget::pane {{ border: 1px solid #c8d6e5; background: white; border-radius: 4px; }}
  46. QTabBar::tab {{ background: #dce8f5; color: #333; padding: 8px 18px; border-radius: 4px 4px 0 0; margin-right: 2px; }}
  47. QTabBar::tab:selected {{ background: {BLUE}; color: white; }}
  48. QPushButton {{
  49. background: {BLUE}; color: white; border: none;
  50. padding: 7px 18px; border-radius: 5px; font-weight: bold;
  51. }}
  52. QPushButton:hover {{ background: {BLUE_DARK}; }}
  53. QPushButton:pressed {{ background: #0f3358; }}
  54. QPushButton:disabled {{ background: #aabdd4; color: #ddd; }}
  55. QPushButton#danger {{ background: #c0392b; color: white; }}
  56. QPushButton#danger:hover {{ background: #a93226; }}
  57. QPushButton#success {{ background: #27ae60; color: white; }}
  58. QPushButton#success:hover {{ background: #1e8449; }}
  59. QPushButton#secondary {{ background: #6c8eaf; color: white; }}
  60. QPushButton#secondary:hover {{ background: #526a87; }}
  61. QLineEdit, QComboBox, QDateTimeEdit, QDoubleSpinBox, QSpinBox {{
  62. border: 1px solid #b0c4d8; border-radius: 4px;
  63. padding: 5px 8px; background: white; color: #222;
  64. }}
  65. QLineEdit:focus, QComboBox:focus {{ border-color: {BLUE}; }}
  66. QComboBox QAbstractItemView {{ background: white; color: #222; selection-background-color: {ACCENT}; selection-color: #1a4a7a; }}
  67. QTableWidget {{ border: 1px solid #c8d6e5; gridline-color: #e0e8f0; background: white; color: #222; }}
  68. QTableWidget::item {{ color: #222; }}
  69. QTableWidget::item:selected {{ background: {ACCENT}; color: #1a4a7a; }}
  70. QHeaderView::section {{
  71. background: {BLUE}; color: white;
  72. padding: 6px; border: none; font-weight: bold;
  73. }}
  74. QGroupBox {{ border: 1px solid #c8d6e5; border-radius: 6px; margin-top: 10px; padding-top: 8px; color: #222; }}
  75. QGroupBox::title {{ subcontrol-origin: margin; left: 10px; color: {BLUE}; font-weight: bold; }}
  76. QLabel {{ color: #222; }}
  77. QLabel#header {{ font-size: 18px; font-weight: bold; color: {BLUE_DARK}; }}
  78. QLabel#hint {{ color: #666; font-size: 11px; }}
  79. QStatusBar {{ background: {BLUE_DARK}; color: white; padding: 2px 8px; }}
  80. QDialogButtonBox QPushButton {{ min-width: 80px; }}
  81. QListWidget {{ background: white; color: #222; border: 1px solid #b0c4d8; border-radius: 4px; }}
  82. QListWidget::item:selected {{ background: {ACCENT}; color: #1a4a7a; }}
  83. """
  84. # ══════════════════════════════════════════════════════════════════════════════
  85. # DATABASE LAYER
  86. # ══════════════════════════════════════════════════════════════════════════════
  87. class Database:
  88. def __init__(self):
  89. self.conn: Optional[psycopg2.extensions.connection] = None
  90. def connect(self, config: dict) -> bool:
  91. try:
  92. self.conn = psycopg2.connect(**config)
  93. self.conn.autocommit = False
  94. return True
  95. except Exception as e:
  96. print(f"DB connect error: {e}")
  97. return False
  98. def disconnect(self):
  99. if self.conn:
  100. self.conn.close()
  101. self.conn = None
  102. def execute(self, sql: str, params=None) -> list:
  103. """Выполнить запрос и вернуть строки."""
  104. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  105. cur.execute(sql, params)
  106. self.conn.commit()
  107. try:
  108. return cur.fetchall()
  109. except psycopg2.ProgrammingError:
  110. return []
  111. def execute_many(self, sql: str, data: list):
  112. with self.conn.cursor() as cur:
  113. psycopg2.extras.execute_batch(cur, sql, data)
  114. self.conn.commit()
  115. def verify_password(self, email: str, password: str):
  116. rows = self.execute(
  117. """
  118. SELECT user_id, last_name, name, mid_name, role, is_active
  119. FROM users
  120. WHERE email = %s AND password_hash = %s
  121. """,
  122. (email, password)
  123. )
  124. if rows and rows[0]["is_active"]:
  125. return dict(rows[0])
  126. return None
  127. # ── TICKETS ──
  128. def get_tickets(self, search: str = "", status: str = "", show_id: int = 0) -> list:
  129. sql = """
  130. SELECT t.ticket_id, s.title AS show_title, t.event_datetime,
  131. h.name AS hall, se.row_number, se.seat_number, se.category,
  132. t.price, t.status, a.manager_name
  133. FROM tickets t
  134. JOIN shows s ON t.show_id = s.show_id
  135. JOIN seats se ON t.seat_id = se.seat_id
  136. JOIN halls h ON se.hall_id = h.hall_id
  137. JOIN accounting a ON t.accounting_id = a.accounting_id
  138. WHERE 1=1
  139. """
  140. params = []
  141. if search:
  142. sql += " AND (s.title ILIKE %s OR se.category ILIKE %s)"
  143. params += [f"%{search}%", f"%{search}%"]
  144. if status:
  145. sql += " AND t.status = %s"
  146. params.append(status)
  147. if show_id:
  148. sql += " AND t.show_id = %s"
  149. params.append(show_id)
  150. sql += " ORDER BY t.event_datetime DESC, t.ticket_id"
  151. return self.execute(sql, params or None)
  152. def add_ticket(self, data: dict) -> bool:
  153. try:
  154. self.execute(
  155. """INSERT INTO tickets (accounting_id, show_id, seat_id, event_datetime, price, status)
  156. VALUES (%(accounting_id)s, %(show_id)s, %(seat_id)s,
  157. %(event_datetime)s, %(price)s, %(status)s)""",
  158. data
  159. )
  160. return True
  161. except Exception as e:
  162. self.conn.rollback()
  163. raise e
  164. def update_ticket(self, ticket_id: int, data: dict) -> bool:
  165. try:
  166. self.execute(
  167. """UPDATE tickets SET show_id=%(show_id)s, seat_id=%(seat_id)s,
  168. event_datetime=%(event_datetime)s, price=%(price)s, status=%(status)s
  169. WHERE ticket_id=%(ticket_id)s""",
  170. {**data, "ticket_id": ticket_id}
  171. )
  172. return True
  173. except Exception as e:
  174. self.conn.rollback()
  175. raise e
  176. def delete_ticket(self, ticket_id: int):
  177. # Нельзя удалить проданный билет
  178. rows = self.execute("SELECT status FROM tickets WHERE ticket_id=%s", (ticket_id,))
  179. if rows and rows[0]["status"] == "продан":
  180. raise ValueError("Нельзя удалить проданный билет!")
  181. self.execute("DELETE FROM tickets WHERE ticket_id=%s", (ticket_id,))
  182. # ── SALES ──
  183. def get_sales(self, search: str = "") -> list:
  184. sql = """
  185. SELECT sa.sale_id, u.last_name || ' ' || u.name || ' ' || u.mid_name AS buyer, u.email,
  186. sh.title AS show_title, t.event_datetime,
  187. t.price, sa.amount_paid, sa.payment_method, sa.sale_datetime
  188. FROM sales sa
  189. JOIN users u ON sa.user_id = u.user_id
  190. JOIN tickets t ON sa.ticket_id = t.ticket_id
  191. JOIN shows sh ON t.show_id = sh.show_id
  192. """
  193. params = []
  194. if search:
  195. sql += """WHERE
  196. last_name ILIKE %s OR name ILIKE %s OR mid_name ILIKE %s OR email ILIKE %s"""
  197. params = [ f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%"]
  198. sql += " ORDER BY sa.sale_datetime DESC"
  199. return self.execute(sql, params or None)
  200. def sell_ticket(self, ticket_id: int, user_id: int, method: str) -> bool:
  201. rows = self.execute("SELECT price, status FROM tickets WHERE ticket_id=%s", (ticket_id,))
  202. if not rows:
  203. raise ValueError("Билет не найден")
  204. t = rows[0]
  205. if t["status"] != "доступен":
  206. raise ValueError(f"Билет недоступен (статус: {t['status']})")
  207. try:
  208. self.execute(
  209. "UPDATE tickets SET status='продан' WHERE ticket_id=%s", (ticket_id,)
  210. )
  211. self.execute(
  212. """INSERT INTO sales (ticket_id, user_id, payment_method, amount_paid)
  213. VALUES (%s, %s, %s, %s)""",
  214. (ticket_id, user_id, method, t["price"])
  215. )
  216. return True
  217. except Exception as e:
  218. self.conn.rollback()
  219. raise e
  220. # ── USERS ──
  221. def get_users(self, search: str = "") -> list:
  222. sql = """SELECT user_id, last_name, name, mid_name, birthdate, phone, email, role, is_active, created_at
  223. FROM users"""
  224. params = []
  225. if search:
  226. sql += " WHERE last_name || ' ' || name || ' ' || mid_name ILIKE %s OR email ILIKE %s"
  227. params = [f"%{search}%", f"%{search}%"]
  228. sql += " ORDER BY last_name, name, mid_name"
  229. return self.execute(sql, params or None)
  230. def add_user(self, data: dict) -> bool:
  231. try:
  232. self.execute(
  233. """INSERT INTO users (last_name, name, mid_name, birthdate, phone, email, password_hash, role)
  234. VALUES (%(last_name)s, %(name)s, %(mid_name)s, %(birthdate)s, %(phone)s, %(email)s,
  235. %(password)s, %(role)s)""",
  236. data
  237. )
  238. return True
  239. except Exception as e:
  240. self.conn.rollback()
  241. raise e
  242. def toggle_user_active(self, user_id: int, active: bool):
  243. self.execute("UPDATE users SET is_active=%s WHERE user_id=%s", (active, user_id))
  244. # ── SHOWS ──
  245. def get_shows(self) -> list:
  246. return self.execute("SELECT * FROM shows ORDER BY title")
  247. # ── LOOKUPS ──
  248. def get_accounting_list(self) -> list:
  249. return self.execute("SELECT accounting_id, manager_name FROM accounting ORDER BY manager_name")
  250. def get_seats_by_hall(self, hall_id: int) -> list:
  251. return self.execute(
  252. "SELECT seat_id, row_number, seat_number, category FROM seats WHERE hall_id=%s ORDER BY row_number, seat_number",
  253. (hall_id,)
  254. )
  255. def get_halls(self) -> list:
  256. return self.execute("SELECT * FROM halls ORDER BY name")
  257. # ── REPORTS ──
  258. def revenue_by_show(self) -> list:
  259. return self.execute("""
  260. SELECT sh.title, COUNT(*) AS sold,
  261. SUM(sa.amount_paid) AS revenue
  262. FROM sales sa
  263. JOIN tickets t ON sa.ticket_id = t.ticket_id
  264. JOIN shows sh ON t.show_id = sh.show_id
  265. GROUP BY sh.title ORDER BY revenue DESC
  266. """)
  267. def get_audit_log(self, limit=50) -> list:
  268. return self.execute(
  269. "SELECT * FROM audit_log ORDER BY changed_at DESC LIMIT %s", (limit,)
  270. )
  271. # ══════════════════════════════════════════════════════════════════════════════
  272. # DIALOGS
  273. # ══════════════════════════════════════════════════════════════════════════════
  274. class LoginDialog(QDialog):
  275. def __init__(self):
  276. super().__init__()
  277. self.setWindowTitle("Вход в систему — Театр")
  278. self.setFixedWidth(380)
  279. self.user_data = None
  280. layout = QVBoxLayout(self)
  281. layout.setSpacing(14)
  282. title = QLabel("🎭 Театр — Вход в систему")
  283. title.setObjectName("header")
  284. title.setAlignment(Qt.AlignmentFlag.AlignCenter)
  285. layout.addWidget(title)
  286. form = QFormLayout()
  287. self.email_edit = QLineEdit()
  288. self.email_edit.setPlaceholderText("example@mail.ru")
  289. self.pass_edit = QLineEdit()
  290. self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
  291. form.addRow("Email:", self.email_edit)
  292. form.addRow("Пароль:", self.pass_edit)
  293. layout.addLayout(form)
  294. hint = QLabel("<i>Демо: admin@teatr.ru / AdminPass!1</i>")
  295. hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
  296. hint.setStyleSheet("color: gray; font-size: 11px;")
  297. layout.addWidget(hint)
  298. btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
  299. btns.accepted.connect(self.try_login)
  300. btns.rejected.connect(self.reject)
  301. layout.addWidget(btns)
  302. self.db = Database()
  303. def set_db_config(self, config: dict):
  304. self.db.connect(config)
  305. def try_login(self):
  306. email = self.email_edit.text().strip()
  307. password = self.pass_edit.text()
  308. if not email or not password:
  309. QMessageBox.warning(self, "Ошибка", "Заполните все поля.")
  310. return
  311. user = self.db.verify_password(email, password)
  312. if user:
  313. self.user_data = user
  314. self.accept()
  315. else:
  316. QMessageBox.critical(self, "Ошибка", "Неверный email или пароль.")
  317. class TicketDialog(QDialog):
  318. """Диалог добавления / редактирования билета."""
  319. def __init__(self, db: Database, ticket=None, parent=None):
  320. super().__init__(parent)
  321. self.db = db
  322. self.ticket = ticket
  323. self.setWindowTitle("Редактировать билет" if ticket else "Добавить билет")
  324. self.setMinimumWidth(420)
  325. layout = QFormLayout(self)
  326. # Спектакль
  327. self.show_cb = QComboBox()
  328. shows = db.get_shows()
  329. for s in shows:
  330. self.show_cb.addItem(s["title"], s["show_id"])
  331. layout.addRow("Спектакль:", self.show_cb)
  332. # Касса
  333. self.acc_cb = QComboBox()
  334. accs = db.get_accounting_list()
  335. for a in accs:
  336. self.acc_cb.addItem(a["manager_name"], a["accounting_id"])
  337. layout.addRow("Кассир:", self.acc_cb)
  338. # Зал → место
  339. self.hall_cb = QComboBox()
  340. halls = db.get_halls()
  341. for h in halls:
  342. self.hall_cb.addItem(h["name"], h["hall_id"])
  343. self.hall_cb.currentIndexChanged.connect(self._load_seats)
  344. layout.addRow("Зал:", self.hall_cb)
  345. self.seat_cb = QComboBox()
  346. layout.addRow("Место:", self.seat_cb)
  347. self._load_seats()
  348. # Дата и время
  349. self.dt_edit = QDateTimeEdit(QDateTime.currentDateTime())
  350. self.dt_edit.setDisplayFormat("dd.MM.yyyy HH:mm")
  351. layout.addRow("Дата/время:", self.dt_edit)
  352. # Цена
  353. self.price_spin = QDoubleSpinBox()
  354. self.price_spin.setRange(0, 99999)
  355. self.price_spin.setSuffix(" ₽")
  356. layout.addRow("Цена:", self.price_spin)
  357. # Статус
  358. self.status_cb = QComboBox()
  359. self.status_cb.addItems(["доступен", "бронь", "продан", "возврат"])
  360. layout.addRow("Статус:", self.status_cb)
  361. btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
  362. btns.accepted.connect(self.accept)
  363. btns.rejected.connect(self.reject)
  364. layout.addRow(btns)
  365. if ticket:
  366. self._fill(ticket)
  367. def _load_seats(self):
  368. hall_id = self.hall_cb.currentData()
  369. self.seat_cb.clear()
  370. if hall_id:
  371. for s in self.db.get_seats_by_hall(hall_id):
  372. label = f"Ряд {s['row_number']}, место {s['seat_number']} ({s['category']})"
  373. self.seat_cb.addItem(label, s["seat_id"])
  374. def _fill(self, t):
  375. idx = self.show_cb.findData(t["show_id"]) if "show_id" in t else -1
  376. if idx >= 0:
  377. self.show_cb.setCurrentIndex(idx)
  378. self.price_spin.setValue(float(t.get("price", 0)))
  379. status_idx = self.status_cb.findText(t.get("status", "доступен"))
  380. if status_idx >= 0:
  381. self.status_cb.setCurrentIndex(status_idx)
  382. def get_data(self) -> dict:
  383. return {
  384. "accounting_id": self.acc_cb.currentData(),
  385. "show_id": self.show_cb.currentData(),
  386. "seat_id": self.seat_cb.currentData(),
  387. "event_datetime": self.dt_edit.dateTime().toString("yyyy-MM-dd HH:mm"),
  388. "price": self.price_spin.value(),
  389. "status": self.status_cb.currentText(),
  390. }
  391. class SellDialog(QDialog):
  392. """Диалог продажи билета."""
  393. def __init__(self, db: Database, ticket_id: int, parent=None):
  394. super().__init__(parent)
  395. self.db = db
  396. self.ticket_id = ticket_id
  397. self.setWindowTitle("Продажа билета")
  398. self.setFixedWidth(380)
  399. layout = QFormLayout(self)
  400. self.user_cb = QComboBox()
  401. users = db.get_users()
  402. fio = f"{u['last_name']} {u['name']} {u['mid_name'] or ''}".strip()
  403. for u in users:
  404. self.user_cb.addItem(f"{u['fio']} ({u['email']})", u["user_id"])
  405. layout.addRow("Покупатель:", self.user_cb)
  406. self.method_cb = QComboBox()
  407. self.method_cb.addItems(["карта", "наличные", "онлайн"])
  408. layout.addRow("Способ оплаты:", self.method_cb)
  409. btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
  410. btns.accepted.connect(self.accept)
  411. btns.rejected.connect(self.reject)
  412. layout.addRow(btns)
  413. class UserDialog(QDialog):
  414. def __init__(self, parent=None):
  415. super().__init__(parent)
  416. self.setWindowTitle("Добавить пользователя")
  417. self.setFixedWidth(400)
  418. layout = QFormLayout(self)
  419. self.last_name_edit = QLineEdit()
  420. self.name_edit = QLineEdit()
  421. self.mid_name_edit = QLineEdit()
  422. self.email_edit = QLineEdit()
  423. self.phone_edit = QLineEdit()
  424. self.birth_edit = QLineEdit()
  425. self.birth_edit.setPlaceholderText("ГГГГ-ММ-ДД")
  426. self.pass_edit = QLineEdit()
  427. self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
  428. self.role_cb = QComboBox()
  429. self.role_cb.addItems(["buyer", "cashier", "admin"])
  430. layout.addRow("Фамилия:", self.last_name_edit)
  431. layout.addRow("Имя:", self.name_edit)
  432. layout.addRow("Отчество:", self.mid_name_edit)
  433. layout.addRow("Email:", self.email_edit)
  434. layout.addRow("Телефон:", self.phone_edit)
  435. layout.addRow("Дата рождения:", self.birth_edit)
  436. layout.addRow("Пароль:", self.pass_edit)
  437. layout.addRow("Роль:", self.role_cb)
  438. btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
  439. btns.accepted.connect(self.accept)
  440. btns.rejected.connect(self.reject)
  441. layout.addRow(btns)
  442. def get_data(self) -> dict:
  443. return {
  444. "last_name": self.last_name_edit.text().strip(),
  445. "name": self.name_edit.text().strip(),
  446. "mid_name": self.mid_name_edit.text().strip(),
  447. "email": self.email_edit.text().strip(),
  448. "phone": self.phone_edit.text().strip(),
  449. "birthdate": self.birth_edit.text().strip() or None,
  450. "password": self.pass_edit.text(),
  451. "role": self.role_cb.currentText(),
  452. }
  453. def make_table(headers: list) -> QTableWidget:
  454. t = QTableWidget(0, len(headers))
  455. t.setHorizontalHeaderLabels(headers)
  456. t.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
  457. t.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
  458. t.horizontalHeader().setStretchLastSection(True)
  459. t.verticalHeader().setVisible(False)
  460. t.setAlternatingRowColors(False)
  461. return t
  462. def fill_table(table: QTableWidget, rows: list, keys: list):
  463. table.setRowCount(0)
  464. for row in rows:
  465. r = table.rowCount()
  466. table.insertRow(r)
  467. for c, k in enumerate(keys):
  468. val = row.get(k, "")
  469. item = QTableWidgetItem(str(val) if val is not None else "")
  470. item.setData(Qt.ItemDataRole.UserRole, row)
  471. table.setItem(r, c, item)
  472. def export_to_csv(table: QTableWidget, parent=None):
  473. path, _ = QFileDialog.getSaveFileName(parent, "Сохранить CSV", "", "CSV (*.csv)")
  474. if not path:
  475. return
  476. with open(path, "w", newline="", encoding="utf-8-sig") as f:
  477. writer = csv.writer(f, delimiter=";")
  478. headers = [table.horizontalHeaderItem(i).text()
  479. for i in range(table.columnCount())]
  480. writer.writerow(headers)
  481. for row in range(table.rowCount()):
  482. writer.writerow([
  483. table.item(row, col).text() if table.item(row, col) else ""
  484. for col in range(table.columnCount())
  485. ])
  486. QMessageBox.information(parent, "Экспорт", f"Файл сохранён:\n{path}")
  487. # ── Вкладка: Билеты ──────────────────────────────────────────────────────────
  488. class TicketsTab(QWidget):
  489. def __init__(self, db: Database, user: dict):
  490. super().__init__()
  491. self.db = db
  492. self.user = user
  493. self._build_ui()
  494. self.refresh()
  495. def _build_ui(self):
  496. layout = QVBoxLayout(self)
  497. # Панель поиска
  498. search_grp = QGroupBox("Поиск и фильтрация")
  499. sg = QHBoxLayout(search_grp)
  500. self.search_edit = QLineEdit()
  501. self.search_edit.setPlaceholderText("Название спектакля, категория...")
  502. self.status_filter = QComboBox()
  503. self.status_filter.addItems(["", "доступен", "продан", "бронь", "возврат"])
  504. sg.addWidget(QLabel("Поиск:"))
  505. sg.addWidget(self.search_edit)
  506. sg.addWidget(QLabel("Статус:"))
  507. sg.addWidget(self.status_filter)
  508. btn_search = QPushButton("🔍 Найти")
  509. btn_search.clicked.connect(self.refresh)
  510. sg.addWidget(btn_search)
  511. layout.addWidget(search_grp)
  512. # Таблица
  513. self.table = make_table([
  514. "ID", "Спектакль", "Дата/время", "Зал",
  515. "Ряд", "Место", "Категория", "Цена ₽", "Статус"
  516. ])
  517. layout.addWidget(self.table)
  518. # Кнопки
  519. btn_row = QHBoxLayout()
  520. btn_add = QPushButton("➕ Добавить")
  521. btn_edit = QPushButton("✏️ Изменить")
  522. btn_del = QPushButton("🗑️ Удалить")
  523. btn_sell = QPushButton("💳 Продать")
  524. btn_exp = QPushButton("📤 Экспорт CSV")
  525. btn_ref = QPushButton("🔄 Обновить")
  526. btn_del.setObjectName("danger")
  527. btn_sell.setObjectName("success")
  528. btn_add.clicked.connect(self.add_ticket)
  529. btn_edit.clicked.connect(self.edit_ticket)
  530. btn_del.clicked.connect(self.delete_ticket)
  531. btn_sell.clicked.connect(self.sell_ticket)
  532. btn_exp.clicked.connect(lambda: export_to_csv(self.table, self))
  533. btn_ref.clicked.connect(self.refresh)
  534. for b in [btn_add, btn_edit, btn_del, btn_sell, btn_exp, btn_ref]:
  535. btn_row.addWidget(b)
  536. # Права доступа — скрываем кнопки управления для покупателя
  537. if self.user["role"] not in ("admin", "cashier"):
  538. for b in (btn_add, btn_edit, btn_del, btn_sell):
  539. b.setVisible(False)
  540. layout.addLayout(btn_row)
  541. def refresh(self):
  542. rows = self.db.get_tickets(
  543. self.search_edit.text(),
  544. self.status_filter.currentText()
  545. )
  546. keys = ["ticket_id","show_title","event_datetime","hall",
  547. "row_number","seat_number","category","price","status"]
  548. fill_table(self.table, rows, keys)
  549. def _selected_id(self) -> Optional[int]:
  550. row = self.table.currentRow()
  551. if row < 0:
  552. QMessageBox.warning(self, "Выбор", "Выберите строку.")
  553. return None
  554. item = self.table.item(row, 0)
  555. return int(item.text())
  556. def add_ticket(self):
  557. dlg = TicketDialog(self.db, parent=self)
  558. if dlg.exec():
  559. try:
  560. self.db.add_ticket(dlg.get_data())
  561. self.refresh()
  562. except Exception as e:
  563. QMessageBox.critical(self, "Ошибка", str(e))
  564. def edit_ticket(self):
  565. tid = self._selected_id()
  566. if tid is None:
  567. return
  568. row = self.table.currentRow()
  569. ticket = {"show_id": None, "price": float(self.table.item(row, 7).text()),
  570. "status": self.table.item(row, 8).text()}
  571. dlg = TicketDialog(self.db, ticket, parent=self)
  572. if dlg.exec():
  573. try:
  574. self.db.update_ticket(tid, dlg.get_data())
  575. self.refresh()
  576. except Exception as e:
  577. QMessageBox.critical(self, "Ошибка", str(e))
  578. def delete_ticket(self):
  579. tid = self._selected_id()
  580. if tid is None:
  581. return
  582. if QMessageBox.question(self, "Удаление", f"Удалить билет #{tid}?") == QMessageBox.StandardButton.Yes:
  583. try:
  584. self.db.delete_ticket(tid)
  585. self.refresh()
  586. except Exception as e:
  587. QMessageBox.critical(self, "Ошибка", str(e))
  588. def sell_ticket(self):
  589. tid = self._selected_id()
  590. if tid is None:
  591. return
  592. dlg = SellDialog(self.db, tid, parent=self)
  593. if dlg.exec():
  594. try:
  595. self.db.sell_ticket(
  596. tid,
  597. dlg.user_cb.currentData(),
  598. dlg.method_cb.currentText()
  599. )
  600. QMessageBox.information(self, "Успех", "Билет продан!")
  601. self.refresh()
  602. except Exception as e:
  603. QMessageBox.critical(self, "Ошибка", str(e))
  604. # ── Вкладка: Продажи ─────────────────────────────────────────────────────────
  605. class SalesTab(QWidget):
  606. def __init__(self, db: Database, user: dict):
  607. super().__init__()
  608. self.db = db
  609. self.user = user
  610. self._build_ui()
  611. self.refresh()
  612. def _build_ui(self):
  613. layout = QVBoxLayout(self)
  614. sh = QHBoxLayout()
  615. self.search_edit = QLineEdit()
  616. self.search_edit.setPlaceholderText("Имя покупателя, спектакль, email...")
  617. btn_s = QPushButton("🔍 Найти")
  618. btn_s.clicked.connect(self.refresh)
  619. btn_e = QPushButton("📤 Экспорт CSV")
  620. btn_e.clicked.connect(lambda: export_to_csv(self.table, self))
  621. sh.addWidget(QLabel("Поиск:"))
  622. sh.addWidget(self.search_edit)
  623. sh.addWidget(btn_s)
  624. sh.addWidget(btn_e)
  625. layout.addLayout(sh)
  626. self.table = make_table([
  627. "ID продажи", "Покупатель", "Email", "Спектакль",
  628. "Дата показа", "Цена ₽", "Оплачено ₽", "Способ", "Дата продажи"
  629. ])
  630. layout.addWidget(self.table)
  631. # Итог
  632. self.total_lbl = QLabel()
  633. self.total_lbl.setStyleSheet(f"color: {BLUE_DARK}; font-weight: bold; padding: 4px;")
  634. layout.addWidget(self.total_lbl)
  635. def refresh(self):
  636. rows = self.db.get_sales(self.search_edit.text())
  637. keys = ["sale_id","buyer","email","show_title",
  638. "event_datetime","price","amount_paid","payment_method","sale_datetime"]
  639. fill_table(self.table, rows, keys)
  640. total = sum(float(r["amount_paid"] or 0) for r in rows)
  641. self.total_lbl.setText(f"Итого: {total:,.2f} ₽ | Записей: {len(rows)}")
  642. # ── Вкладка: Пользователи ────────────────────────────────────────────────────
  643. class UsersTab(QWidget):
  644. def __init__(self, db: Database, user: dict):
  645. super().__init__()
  646. self.db = db
  647. self.user = user
  648. self._build_ui()
  649. self.refresh()
  650. def _build_ui(self):
  651. layout = QVBoxLayout(self)
  652. sh = QHBoxLayout()
  653. self.search_edit = QLineEdit()
  654. self.search_edit.setPlaceholderText("ФИО, email...")
  655. btn_s = QPushButton("🔍 Найти")
  656. btn_s.clicked.connect(self.refresh)
  657. sh.addWidget(QLabel("Поиск:"))
  658. sh.addWidget(self.search_edit)
  659. sh.addWidget(btn_s)
  660. layout.addLayout(sh)
  661. self.table = make_table([
  662. "ID", "Фaмилия", "Имя", "Отчество", "Дата рожд.", "Телефон", "Email", "Роль", "Активен", "Дата регистр."
  663. ])
  664. layout.addWidget(self.table)
  665. btn_row = QHBoxLayout()
  666. btn_add = QPushButton("➕ Добавить")
  667. btn_toggle = QPushButton("🔒 Активировать/Блок.")
  668. btn_exp = QPushButton("📤 Экспорт CSV")
  669. btn_ref = QPushButton("🔄 Обновить")
  670. btn_add.clicked.connect(self.add_user)
  671. btn_toggle.clicked.connect(self.toggle_user)
  672. btn_exp.clicked.connect(lambda: export_to_csv(self.table, self))
  673. btn_ref.clicked.connect(self.refresh)
  674. for b in [btn_add, btn_toggle, btn_exp, btn_ref]:
  675. btn_row.addWidget(b)
  676. if self.user["role"] != "admin":
  677. btn_add.setVisible(False)
  678. btn_toggle.setVisible(False)
  679. layout.addLayout(btn_row)
  680. def refresh(self):
  681. rows = self.db.get_users(self.search_edit.text())
  682. keys = [
  683. "user_id",
  684. "last_name",
  685. "name",
  686. "mid_name",
  687. "birthdate",
  688. "phone",
  689. "email",
  690. "role",
  691. "is_active",
  692. "created_at"
  693. ]
  694. fill_table(self.table, rows, keys)
  695. def add_user(self):
  696. dlg = UserDialog(self)
  697. if dlg.exec():
  698. try:
  699. self.db.add_user(dlg.get_data())
  700. self.refresh()
  701. except Exception as e:
  702. QMessageBox.critical(self, "Ошибка", str(e))
  703. def toggle_user(self):
  704. row = self.table.currentRow()
  705. if row < 0:
  706. QMessageBox.warning(self, "Выбор", "Выберите пользователя.")
  707. return
  708. uid = int(self.table.item(row, 0).text())
  709. active = self.table.item(row, 6).text() == "True"
  710. self.db.toggle_user_active(uid, not active)
  711. self.refresh()
  712. # ── Вкладка: Отчёты ──────────────────────────────────────────────────────────
  713. class ReportsTab(QWidget):
  714. def __init__(self, db: Database):
  715. super().__init__()
  716. self.db = db
  717. self._build_ui()
  718. def _build_ui(self):
  719. layout = QVBoxLayout(self)
  720. btn_rev = QPushButton("📊 Выручка по спектаклям")
  721. btn_rev.clicked.connect(self.show_revenue)
  722. btn_aud = QPushButton("📋 Журнал аудита")
  723. btn_aud.clicked.connect(self.show_audit)
  724. row = QHBoxLayout()
  725. row.addWidget(btn_rev)
  726. row.addWidget(btn_aud)
  727. row.addStretch()
  728. layout.addLayout(row)
  729. self.report_table = make_table(["Спектакль", "Продано билетов", "Выручка ₽"])
  730. layout.addWidget(self.report_table)
  731. self.summary_lbl = QLabel()
  732. self.summary_lbl.setStyleSheet(f"color: {BLUE_DARK}; font-weight: bold;")
  733. layout.addWidget(self.summary_lbl)
  734. btn_exp = QPushButton("📤 Экспорт CSV")
  735. btn_exp.clicked.connect(lambda: export_to_csv(self.report_table, self))
  736. layout.addWidget(btn_exp)
  737. def show_revenue(self):
  738. rows = self.db.revenue_by_show()
  739. self.report_table.setColumnCount(3)
  740. self.report_table.setHorizontalHeaderLabels(["Спектакль", "Продано", "Выручка ₽"])
  741. fill_table(self.report_table, rows, ["title", "sold", "revenue"])
  742. total = sum(float(r["revenue"] or 0) for r in rows)
  743. self.summary_lbl.setText(f"Общая выручка: {total:,.2f} ₽")
  744. def show_audit(self):
  745. rows = self.db.get_audit_log()
  746. self.report_table.setColumnCount(5)
  747. self.report_table.setHorizontalHeaderLabels(
  748. ["ID", "Таблица", "Операция", "Пользователь", "Дата/время"]
  749. )
  750. fill_table(self.report_table, rows,
  751. ["log_id","table_name","operation","changed_by","changed_at"])
  752. self.summary_lbl.setText(f"Записей в журнале: {len(rows)}")
  753. # ══════════════════════════════════════════════════════════════════════════════
  754. # MAIN WINDOW
  755. # ══════════════════════════════════════════════════════════════════════════════
  756. class MainWindow(QMainWindow):
  757. def __init__(self, db: Database, user: dict):
  758. super().__init__()
  759. self.db = db
  760. self.user = user
  761. fio = f"{user['last_name']} {user['name']}"
  762. if user['mid_name']:
  763. fio += f" {user['mid_name']}"
  764. self.setWindowTitle(f"Театр — {fio} ({APP_ROLES.get(user['role'], user['role'])})")
  765. self.resize(1100, 680)
  766. # Статус-бар
  767. sb = QStatusBar()
  768. self.setStatusBar(sb)
  769. sb.showMessage(f"Подключено | Пользователь: {fio} | Роль: {APP_ROLES.get(user['role'])}")
  770. # Вкладки
  771. tabs = QTabWidget()
  772. role = user["role"]
  773. # Вкладка «Билеты» — доступна всем
  774. tabs.addTab(TicketsTab(db, user), "🎫 Билеты")
  775. # Вкладка «Продажи» — только admin и cashier
  776. if role in ("admin", "cashier"):
  777. tabs.addTab(SalesTab(db, user), "💳 Продажи")
  778. # Вкладка «Пользователи» — только admin
  779. if role == "admin":
  780. tabs.addTab(UsersTab(db, user), "👤 Пользователи")
  781. # Вкладка «Отчёты» — только admin и cashier
  782. if role in ("admin", "cashier"):
  783. tabs.addTab(ReportsTab(db), "📊 Отчёты")
  784. self.setCentralWidget(tabs)
  785. # ══════════════════════════════════════════════════════════════════════════════
  786. # ENTRY POINT
  787. # ══════════════════════════════════════════════════════════════════════════════
  788. import json
  789. # ── Хранилище профилей подключения ──────────────────────────────────────────
  790. PROFILES_FILE = os.path.join(os.path.expanduser("~"), ".theatre_db_profiles.json")
  791. def load_profiles() -> list:
  792. """Загрузить сохранённые профили подключения."""
  793. try:
  794. if os.path.exists(PROFILES_FILE):
  795. with open(PROFILES_FILE, "r", encoding="utf-8") as f:
  796. return json.load(f)
  797. except Exception:
  798. pass
  799. return []
  800. def save_profiles(profiles: list):
  801. """Сохранить профили подключения."""
  802. try:
  803. with open(PROFILES_FILE, "w", encoding="utf-8") as f:
  804. json.dump(profiles, f, ensure_ascii=False, indent=2)
  805. except Exception as e:
  806. print(f"Ошибка сохранения профилей: {e}")
  807. class DBConnectDialog(QDialog):
  808. """
  809. Диалог выбора/создания профиля подключения к БД.
  810. Показывает список сохранённых пар (логин, пароль).
  811. Позволяет выбрать, удалить или добавить новый профиль.
  812. """
  813. def __init__(self):
  814. super().__init__()
  815. self.setWindowTitle("Подключение к базе данных")
  816. self.setFixedWidth(440)
  817. self.selected_config: dict | None = None
  818. self._profiles = load_profiles()
  819. self._build_ui()
  820. def _build_ui(self):
  821. from PyQt6.QtWidgets import QListWidget, QListWidgetItem
  822. layout = QVBoxLayout(self)
  823. layout.setSpacing(12)
  824. title = QLabel("🎭 Театр — Подключение к БД")
  825. title.setObjectName("header")
  826. title.setAlignment(Qt.AlignmentFlag.AlignCenter)
  827. layout.addWidget(title)
  828. # Группа сохранённых профилей
  829. saved_grp = QGroupBox("Сохранённые подключения")
  830. sg = QVBoxLayout(saved_grp)
  831. self.list_widget = QListWidget()
  832. self.list_widget.setMinimumHeight(120)
  833. self._populate_list()
  834. self.list_widget.itemDoubleClicked.connect(self._accept_selected)
  835. sg.addWidget(self.list_widget)
  836. btn_row = QHBoxLayout()
  837. self.btn_connect = QPushButton("✅ Подключиться")
  838. self.btn_connect.setObjectName("success")
  839. self.btn_connect.clicked.connect(self._accept_selected)
  840. btn_delete = QPushButton("🗑️ Удалить")
  841. btn_delete.setObjectName("danger")
  842. btn_delete.clicked.connect(self._delete_selected)
  843. btn_row.addWidget(self.btn_connect)
  844. btn_row.addWidget(btn_delete)
  845. sg.addLayout(btn_row)
  846. layout.addWidget(saved_grp)
  847. # Разделитель
  848. line = QFrame()
  849. line.setFrameShape(QFrame.Shape.HLine)
  850. line.setFrameShadow(QFrame.Shadow.Sunken)
  851. layout.addWidget(line)
  852. # Группа нового подключения
  853. new_grp = QGroupBox("Новое подключение")
  854. ng = QFormLayout(new_grp)
  855. self.user_edit = QLineEdit()
  856. self.user_edit.setPlaceholderText("Пользователь PostgreSQL")
  857. self.pass_edit = QLineEdit()
  858. self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
  859. self.pass_edit.setPlaceholderText("Пароль")
  860. ng.addRow("Логин:", self.user_edit)
  861. ng.addRow("Пароль:", self.pass_edit)
  862. btn_new = QPushButton("🔌 Подключиться с новым аккаунтом")
  863. btn_new.setObjectName("secondary")
  864. btn_new.clicked.connect(self._connect_new)
  865. ng.addRow(btn_new)
  866. layout.addWidget(new_grp)
  867. # Кнопка отмены
  868. btn_cancel = QPushButton("Отмена")
  869. btn_cancel.setObjectName("secondary")
  870. btn_cancel.clicked.connect(self.reject)
  871. layout.addWidget(btn_cancel)
  872. self._update_connect_btn_state()
  873. self.list_widget.currentRowChanged.connect(lambda _: self._update_connect_btn_state())
  874. def _populate_list(self):
  875. self.list_widget.clear()
  876. for p in self._profiles:
  877. self.list_widget.addItem(f"👤 {p['user']}")
  878. if self._profiles:
  879. self.list_widget.setCurrentRow(0)
  880. def _update_connect_btn_state(self):
  881. self.btn_connect.setEnabled(self.list_widget.currentRow() >= 0)
  882. def _build_config(self, user: str, password: str) -> dict:
  883. base = DB_CONFIG.copy()
  884. base["user"] = user
  885. base["password"] = password
  886. return base
  887. def _accept_selected(self):
  888. idx = self.list_widget.currentRow()
  889. if idx < 0:
  890. QMessageBox.warning(self, "Выбор", "Выберите профиль из списка.")
  891. return
  892. p = self._profiles[idx]
  893. config = self._build_config(p["user"], p["password"])
  894. db_test = Database()
  895. if not db_test.connect(config):
  896. QMessageBox.critical(self, "Ошибка подключения",
  897. f"Не удалось подключиться с логином «{p['user']}».\n"
  898. "Возможно, данные устарели — удалите профиль и добавьте заново.")
  899. return
  900. db_test.disconnect()
  901. self.selected_config = config
  902. self.accept()
  903. def _connect_new(self):
  904. user = self.user_edit.text().strip()
  905. password = self.pass_edit.text()
  906. if not user:
  907. QMessageBox.warning(self, "Ввод", "Введите логин.")
  908. return
  909. # Проверяем дубликат по логину
  910. existing_users = [p["user"] for p in self._profiles]
  911. if user in existing_users:
  912. QMessageBox.information(self, "Уже есть",
  913. f"Профиль с логином «{user}» уже сохранён.\nВыберите его из списка.")
  914. # Выбрать соответствующий элемент в списке
  915. idx = existing_users.index(user)
  916. self.list_widget.setCurrentRow(idx)
  917. return
  918. config = self._build_config(user, password)
  919. db_test = Database()
  920. if not db_test.connect(config):
  921. QMessageBox.critical(self, "Ошибка подключения",
  922. "Не удалось подключиться.\nПроверьте логин и пароль.")
  923. return
  924. db_test.disconnect()
  925. # Сохраняем новый профиль
  926. self._profiles.append({"user": user, "password": password})
  927. save_profiles(self._profiles)
  928. self._populate_list()
  929. self.list_widget.setCurrentRow(len(self._profiles) - 1)
  930. self.selected_config = config
  931. self.accept()
  932. def _delete_selected(self):
  933. idx = self.list_widget.currentRow()
  934. if idx < 0:
  935. return
  936. user = self._profiles[idx]["user"]
  937. reply = QMessageBox.question(
  938. self, "Удаление профиля",
  939. f"Удалить профиль «{user}»?",
  940. QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
  941. )
  942. if reply == QMessageBox.StandardButton.Yes:
  943. self._profiles.pop(idx)
  944. save_profiles(self._profiles)
  945. self._populate_list()
  946. self._update_connect_btn_state()
  947. def main():
  948. app = QApplication(sys.argv)
  949. app.setStyleSheet(STYLE)
  950. # Диалог выбора подключения к БД (только логин + пароль)
  951. conn_dlg = DBConnectDialog()
  952. if conn_dlg.exec() != QDialog.DialogCode.Accepted:
  953. sys.exit(0)
  954. config = conn_dlg.selected_config
  955. db = Database()
  956. if not db.connect(config):
  957. QMessageBox.critical(None, "Ошибка", "Не удалось подключиться к базе данных.")
  958. sys.exit(1)
  959. # Логин в приложение
  960. login = LoginDialog()
  961. login.set_db_config(config)
  962. if login.exec() != QDialog.DialogCode.Accepted:
  963. sys.exit(0)
  964. window = MainWindow(db, login.user_data)
  965. window.show()
  966. sys.exit(app.exec())
  967. if __name__ == "__main__":
  968. main()