Загрузка данных
# -*- coding: utf-8 -*-
import sys
import os
import sqlite3
import csv
import time
import glob
import logging
import functools
import shutil
from string import Template
from datetime import date, datetime, timedelta
from logging.handlers import RotatingFileHandler
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtGui import (QColor, QDesktopServices, QTextCharFormat,
QKeySequence, QFont, QPen, QPalette,
QBrush, QIcon)
from PyQt5.QtCore import QUrl, Qt
APP_VERSION = "1.1.0"
BACKUP_KEEP = 10 # сколько последних бэкапов базы хранить
def get_app_dir():
"""Папка приложения: рядом с .py или собранным exe/ELF."""
if getattr(sys, "frozen", False):
# Запущен как собранный бинарник PyInstaller
return os.path.dirname(sys.executable)
# Запущен как скрипт
return os.path.dirname(os.path.abspath(__file__))
def get_db_path():
"""Путь к общей базе данных.
Приоритет:
1. Переменная окружения VKS_DB_PATH (задают ярлыки/установщик).
2. Реестр Windows (если ставили через установщик).
3. Папка рядом с приложением (.py или собранный exe/ELF).
"""
# 1. Переменная окружения
env_path = os.environ.get("VKS_DB_PATH")
if env_path:
return env_path
# 2. Реестр Windows
if sys.platform == "win32":
try:
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\VKSBooking")
db_dir, _ = winreg.QueryValueEx(key, "DBPath")
winreg.CloseKey(key)
if db_dir:
return os.path.join(db_dir, "bookings.db")
except Exception:
pass
# 3. Папка приложения (работает и для .py, и для собранного exe/ELF)
if getattr(sys, "frozen", False):
app_dir = os.path.dirname(sys.executable)
else:
app_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(app_dir, "bookings.db")
DB_PATH = get_db_path()
log = logging.getLogger("vks_app")
def setup_logging():
"""Пишет лог в vks_app.log рядом с приложением (ротация: 3 файла по 1 МБ)."""
try:
log_path = os.path.join(get_app_dir(), "vks_app.log")
handler = RotatingFileHandler(
log_path, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
)
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S"
))
log.addHandler(handler)
log.setLevel(logging.INFO)
except Exception:
# Логирование не должно мешать запуску приложения
logging.basicConfig(level=logging.INFO)
def backup_database(keep=BACKUP_KEEP):
"""Автобэкап базы при запуске: копия в папку backups рядом с БД.
Использует shutil.copy2 — надёжно работает даже на SMB/FUSE шарах,
где sqlite3.backup() API иногда ломает дескрипторы.
Хранит только `keep` последних копий."""
try:
if not os.path.exists(DB_PATH):
return None
backup_dir = os.path.join(os.path.dirname(DB_PATH) or ".", "backups")
os.makedirs(backup_dir, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dst_path = os.path.join(backup_dir, f"bookings_{stamp}.db")
# Копируем файл как обычные байты (без sqlite API)
import shutil
shutil.copy2(DB_PATH, dst_path)
# Чистим старые копии
old = sorted(glob.glob(os.path.join(backup_dir, "bookings_*.db")))
for path in old[:-keep] if keep > 0 else old:
try:
os.remove(path)
except OSError:
pass
log.info("Бэкап базы создан: %s", dst_path)
return dst_path
except Exception as e:
log.warning("Не удалось создать бэкап базы: %s", e)
return None
def db_retry(retries=5, delay=0.4):
"""Повторяет операцию, если база momentarily занята другим пользователем."""
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for i in range(retries):
try:
return fn(*args, **kwargs)
except sqlite3.OperationalError as e:
if ("locked" in str(e) or "busy" in str(e)) and i < retries - 1:
time.sleep(delay * (i + 1))
else:
raise
return wrapper
return deco
MAX_VKS_ROOMS_PER_DAY = 2 # комнат ВКС в день
MAX_VKS_SEATS_PER_DAY = 10 # лицензий ВКС в день
MAX_WEB_ROOMS_PER_DAY = 12 # комнат вебинаров в день
MAX_WEB_SEATS_PER_DAY = 600 # лицензий вебинаров в день
RU_WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
NO_LICENSE_MARK = "✕" # крестик для ячеек, где лицензий не осталось
# ========== ЦВЕТА ИНТЕРФЕЙСА: МЕНЯЙТЕ ЗДЕСЬ ==========
COLORS = {
"header": "#D4D4D4", # обычный заголовок колонки
"header_weekend": "#FC9C9C", # заголовок выходного дня
"header_selected": "#FDF1DC", # заголовок даты, выбранной в календаре сверху
"header_today": "#FEFED3", # заголовок сегодняшнего дня
"vks_booked": "#BDD6F4", # ЯЧЕЙКА С БРОНИРОВАНИЕМ ВКС ← вот то, что вы искали
"webinar_booked": "#C7F9EA", # ЯЧЕЙКА С БРОНИРОВАНИЕМ ВЕБИНАРА ← вот то, что вы искали
"free_vks": "#f2f8fc", # пустая ячейка в комнате ВКС (будни)
"free_webinar": "#f2fbf8", # пустая ячейка в комнате вебинаров (будни)
"free_weekend": "#fdf2e9", # свободная ячейка в выходной
"monday_line": "#7f8c8d", # вертикальная линия начала недели
"row_vks": "#d6e9fa", # заголовок строки ВКС
"row_webinar": "#d4f5eb", # заголовок строки вебинара
"booked_no_link": "#d3d7da", # бронирование БЕЗ ссылки (ссылки ещё не отправлены)
"free_no_licenses": "#f5b7b1", # фон свободной ячейки без лицензий (красный)
"free_no_licenses_text": "#FFFFFF", # цвет крестика
"search_dim": "#e6e9ec", # затемнённые ячейки, не подходящие под поиск
"search_match_border": "#e67e22", # рамка ячейки, совпавшей с поиском
"grid_line": "#d5dbdb", # линии сетки таблицы
}
# ======================================================
# ====== ПОЧТА ДЛЯ ЗАПРОСА ЗАРУБЕЖНЫХ ССЫЛОК: МЕНЯЙТЕ АДРЕСА ЗДЕСЬ ======
FOREIGN_EMAIL_TO = "1111@Greenatom.ru" # поле «Кому» (подставляется автоматически)
FOREIGN_EMAIL_CC = "ZAGLUSHKA@example.com" # поле «Копия» ← ЗАГЛУШКА: замените на нужный адрес
# Пока в FOREIGN_EMAIL_CC стоит заглушка (слово ZAGLUSHKA), «Копия» в письмо не добавляется.
# ========================================================================
# ====== SVG-ИКОНКИ (единый стиль вместо эмодзи) ======
# Каждая иконка — набор путей в viewBox 24x24, рисуется контуром заданного цвета.
_ICON_BODIES = {
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
"trash": '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>',
"mail": '<rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,5 12,13 22,5"/>',
"copy": '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
"globe": '<circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>',
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
"refresh": '<polyline points="21 4 21 10 15 10"/><polyline points="3 20 3 14 9 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L3 8"/><path d="M3.51 15A9 9 0 0 0 18.36 18.36L21 16"/>',
"export": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
"link": '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
"moon": '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
"sun": '<circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.3" y2="6.3"/><line x1="17.7" y1="17.7" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.3" y2="17.7"/><line x1="17.7" y1="6.3" x2="19.1" y2="4.9"/>',
"unlock": '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
}
def make_icon(name, color="#ffffff", size=64, stroke_width=2):
"""Рендерит QIcon из встроенного SVG-шаблона заданным цветом.
Возвращает пустой QIcon, если QtSvg недоступен (кнопки останутся с текстом)."""
body = _ICON_BODIES.get(name)
if not body:
return QIcon()
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
f'width="{size}" height="{size}">'
f'<g fill="none" stroke="{color}" stroke-width="{stroke_width}" '
f'stroke-linecap="round" stroke-linejoin="round">{body}</g></svg>'
)
try:
from PyQt5 import QtSvg
renderer = QtSvg.QSvgRenderer(QtCore.QByteArray(svg.encode("utf-8")))
pixmap = QtGui.QPixmap(size, size)
pixmap.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(pixmap)
renderer.render(painter)
painter.end()
return QIcon(pixmap)
except Exception:
return QIcon()
def make_copy_button(line_edit, tooltip="Копировать значение в буфер обмена"):
"""Маленькая кнопка-иконка, копирующая текст из связанного QLineEdit."""
btn = QtWidgets.QToolButton()
btn.setObjectName("copyFieldButton")
btn.setCursor(Qt.PointingHandCursor)
btn.setFixedSize(26, 26)
btn.setToolTip(tooltip)
btn.setAutoRaise(True)
color = "#9db0c6" if CURRENT_THEME == "dark" else "#64748b"
btn.setIcon(make_icon("copy", color))
def _copy():
text = line_edit.text()
QtWidgets.QApplication.clipboard().setText(text)
btn.setToolTip("Скопировано!" if text else "Поле пустое")
btn.clicked.connect(_copy)
return btn
def field_with_copy(line_edit, tooltip="Копировать значение в буфер обмена"):
"""Оборачивает поле в контейнер с кнопкой копирования справа."""
container = QtWidgets.QWidget()
row = QtWidgets.QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
row.addWidget(line_edit, 1)
row.addWidget(make_copy_button(line_edit, tooltip))
return container
def plural_uchastnik(n):
n = abs(int(n))
last_two = n % 100
last = n % 10
if 11 <= last_two <= 14:
return "участников"
if last == 1:
return "участник"
if 2 <= last <= 4:
return "участника"
return "участников"
# ---------- Работа с базой данных ----------
def get_connection():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
# Без PRAGMA на FUSE-SMB — пробуем запустить
return conn
def get_room_type(room_id):
"""Получает тип комнаты по ID (vks или webinar)."""
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT type FROM rooms WHERE id = ?", (room_id,))
row = cur.fetchone()
conn.close()
return row["type"] if row else None
@db_retry()
def init_db():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
capacity INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
);
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
client_name TEXT NOT NULL,
description TEXT,
seats_used INTEGER NOT NULL DEFAULT 1,
link TEXT,
FOREIGN KEY(room_id) REFERENCES rooms(id)
);
""")
conn.commit()
cur.execute("PRAGMA table_info(bookings);")
cols = [row["name"] for row in cur.fetchall()]
# Миграция: добавляем новые поля
new_columns = [
("link", "TEXT"),
("time_open", "TEXT"),
("time_close", "TEXT"),
("guest_link", "TEXT"),
("doklad_link", "TEXT"),
("moder_link", "TEXT"),
("event_id", "TEXT"),
("contact_name", "TEXT"),
("training", "TEXT"), # ← новое: Обучение
("event_name", "TEXT"), # ← новое: Мероприятие
("has_foreign", "INTEGER DEFAULT 0"), # ← флаг: есть ли зарубежные слушатели
("foreign_guest_link", "TEXT"),
("foreign_doklad_link", "TEXT"),
("foreign_moder_link", "TEXT"),
("foreign_event_id", "TEXT"),
("foreign_country", "TEXT"),
]
for col_name, col_type in new_columns:
if col_name not in cols:
cur.execute(f"ALTER TABLE bookings ADD COLUMN {col_name} {col_type};")
conn.commit()
# Старые бронирования: копируем description в event_name, чтобы они отображались
cur.execute("""
UPDATE bookings SET event_name = description
WHERE (event_name IS NULL OR event_name = '') AND description IS NOT NULL AND description != '';
""")
conn.commit()
cur.execute("SELECT COUNT(*) AS c FROM rooms;")
if cur.fetchone()["c"] == 0:
rooms_to_insert = []
for i in range(1, MAX_VKS_ROOMS_PER_DAY + 1):
rooms_to_insert.append((f"ВКС-{i}", "vks", 0))
for i in range(1, MAX_WEB_ROOMS_PER_DAY + 1):
rooms_to_insert.append((f"Вебинар-{i}", "webinar", 0))
cur.executemany(
"INSERT INTO rooms (name, type, capacity) VALUES (?, ?, ?);",
rooms_to_insert
)
conn.commit()
# Миграция существующей базы под новые лимиты:
# деактивируем лишние ВКС, добираем вебинарные комнаты
cur.execute(
"UPDATE rooms SET active = 0 WHERE type = 'vks' AND name NOT IN ('ВКС-1', 'ВКС-2');"
)
for i in range(1, MAX_WEB_ROOMS_PER_DAY + 1):
cur.execute("SELECT COUNT(*) AS c FROM rooms WHERE name = ?;", (f"Вебинар-{i}",))
if cur.fetchone()["c"] == 0:
cur.execute(
"INSERT INTO rooms (name, type, capacity) VALUES (?, 'webinar', 0);",
(f"Вебинар-{i}",)
)
conn.commit()
conn.close()
@db_retry()
def fetch_rooms():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT * FROM rooms
WHERE active = 1
ORDER BY CASE type WHEN 'vks' THEN 0 ELSE 1 END, id;
""")
rows = cur.fetchall()
conn.close()
return rows
@db_retry()
def fetch_free_rooms(start_date, end_date):
"""Возвращает только те активные комнаты, где нет бронирований на указанный диапазон дат."""
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
SELECT * FROM rooms
WHERE active = 1
AND id NOT IN (
SELECT DISTINCT room_id FROM bookings
WHERE date BETWEEN ? AND ?
)
ORDER BY CASE type WHEN 'vks' THEN 0 ELSE 1 END, id;
""",
(start_date.isoformat(), end_date.isoformat())
)
rows = cur.fetchall()
conn.close()
return rows
@db_retry()
def fetch_bookings(start_date, end_date):
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
SELECT b.*, r.name AS room_name, r.type AS room_type
FROM bookings b
JOIN rooms r ON r.id = b.room_id
WHERE date BETWEEN ? AND ?
""",
(start_date.isoformat(), end_date.isoformat())
)
rows = cur.fetchall()
conn.close()
return rows
@db_retry()
def get_day_stats(day):
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
SELECT r.type AS type,
COUNT(DISTINCT b.room_id) AS rooms_used,
COALESCE(SUM(b.seats_used), 0) AS seats_used
FROM bookings b
JOIN rooms r ON r.id = b.room_id
WHERE b.date = ?
GROUP BY r.type;
""",
(day.isoformat(),)
)
stats = {}
for row in cur.fetchall():
stats[row["type"]] = {
"rooms_used": row["rooms_used"],
"seats_used": row["seats_used"],
}
conn.close()
if "vks" not in stats:
stats["vks"] = {"rooms_used": 0, "seats_used": 0}
if "webinar" not in stats:
stats["webinar"] = {"rooms_used": 0, "seats_used": 0}
return stats
@db_retry()
def get_booking_range(room_id, target_day):
conn = get_connection()
cur = conn.cursor()
cur.execute(
"SELECT * FROM bookings WHERE room_id = ? AND date = ?;",
(room_id, target_day.isoformat())
)
row = cur.fetchone()
if row is None:
conn.close()
return None
client = row["client_name"]
desc = row["description"]
seats = row["seats_used"]
link = row["link"] if "link" in row.keys() else None
time_open = row["time_open"] if "time_open" in row.keys() else None
time_close = row["time_close"] if "time_close" in row.keys() else None
guest_link = row["guest_link"] if "guest_link" in row.keys() else None
doklad_link = row["doklad_link"] if "doklad_link" in row.keys() else None
moder_link = row["moder_link"] if "moder_link" in row.keys() else None
event_id = row["event_id"] if "event_id" in row.keys() else None
contact_name = row["contact_name"] if "contact_name" in row.keys() else None
training = row["training"] if "training" in row.keys() else None
event_name = row["event_name"] if "event_name" in row.keys() else None
has_foreign = row["has_foreign"] if "has_foreign" in row.keys() else 0
foreign_guest_link = row["foreign_guest_link"] if "foreign_guest_link" in row.keys() else None
foreign_doklad_link = row["foreign_doklad_link"] if "foreign_doklad_link" in row.keys() else None
foreign_moder_link = row["foreign_moder_link"] if "foreign_moder_link" in row.keys() else None
foreign_event_id = row["foreign_event_id"] if "foreign_event_id" in row.keys() else None
foreign_country = row["foreign_country"] if "foreign_country" in row.keys() else None
start_date = target_day
d = target_day - timedelta(days=1)
while True:
cur.execute(
"SELECT * FROM bookings WHERE room_id = ? AND date = ?;",
(room_id, d.isoformat())
)
r = cur.fetchone()
if (r is not None and
r["client_name"] == client and
r["description"] == desc and
r["seats_used"] == seats and
(("link" in r.keys() and r["link"] == link) or ("link" not in r.keys() and link is None)) and
(("time_open" in r.keys() and r["time_open"] == time_open) or ("time_open" not in r.keys() and time_open is None)) and
(("time_close" in r.keys() and r["time_close"] == time_close) or ("time_close" not in r.keys() and time_close is None)) and
(("guest_link" in r.keys() and r["guest_link"] == guest_link) or ("guest_link" not in r.keys() and guest_link is None)) and
(("doklad_link" in r.keys() and r["doklad_link"] == doklad_link) or ("doklad_link" not in r.keys() and doklad_link is None)) and
(("moder_link" in r.keys() and r["moder_link"] == moder_link) or ("moder_link" not in r.keys() and moder_link is None)) and
(("event_id" in r.keys() and r["event_id"] == event_id) or ("event_id" not in r.keys() and event_id is None)) and
(("contact_name" in r.keys() and r["contact_name"] == contact_name) or ("contact_name" not in r.keys() and contact_name is None))):
start_date = d
d -= timedelta(days=1)
else:
break
end_date = target_day
d = target_day + timedelta(days=1)
while True:
cur.execute(
"SELECT * FROM bookings WHERE room_id = ? AND date = ?;",
(room_id, d.isoformat())
)
r = cur.fetchone()
if (r is not None and
r["client_name"] == client and
r["description"] == desc and
r["seats_used"] == seats and
(("link" in r.keys() and r["link"] == link) or ("link" not in r.keys() and link is None)) and
(("time_open" in r.keys() and r["time_open"] == time_open) or ("time_open" not in r.keys() and time_open is None)) and
(("time_close" in r.keys() and r["time_close"] == time_close) or ("time_close" not in r.keys() and time_close is None)) and
(("guest_link" in r.keys() and r["guest_link"] == guest_link) or ("guest_link" not in r.keys() and guest_link is None)) and
(("doklad_link" in r.keys() and r["doklad_link"] == doklad_link) or ("doklad_link" not in r.keys() and doklad_link is None)) and
(("moder_link" in r.keys() and r["moder_link"] == moder_link) or ("moder_link" not in r.keys() and moder_link is None)) and
(("event_id" in r.keys() and r["event_id"] == event_id) or ("event_id" not in r.keys() and event_id is None)) and
(("contact_name" in r.keys() and r["contact_name"] == contact_name) or ("contact_name" not in r.keys() and contact_name is None))):
end_date = d
d += timedelta(days=1)
else:
break
conn.close()
return (start_date, end_date, client, desc, seats, link,
time_open, time_close, guest_link, doklad_link, moder_link,
event_id, contact_name, training, event_name,
has_foreign, foreign_guest_link, foreign_doklad_link,
foreign_moder_link, foreign_event_id, foreign_country)
@db_retry()
def find_contact_name_by_last_name(last_name):
"""Ищет контактное имя по фамилии в уже существующих бронированиях.
Регистронезависимое сравнение делается в Python: встроенный COLLATE NOCASE
в SQLite работает только для ASCII и НЕ поддерживает кириллицу."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT client_name, contact_name FROM bookings
WHERE contact_name IS NOT NULL
AND contact_name != ''
ORDER BY id DESC
""")
target = last_name.lower()
result = None
for row in cur.fetchall():
client = row["client_name"] or ""
if client.lower().startswith(target):
result = row["contact_name"]
break
conn.close()
return result
@db_retry()
def delete_booking_range(room_id, start_date, end_date, client_name, description, seats_used, link=None):
conn = get_connection()
cur = conn.cursor()
if link is None:
cur.execute(
"""
DELETE FROM bookings
WHERE room_id = ? AND date BETWEEN ? AND ?
AND client_name = ? AND description = ? AND seats_used = ?
AND link IS NULL;
""",
(room_id, start_date.isoformat(), end_date.isoformat(), client_name, description, seats_used)
)
else:
cur.execute(
"""
DELETE FROM bookings
WHERE room_id = ? AND date BETWEEN ? AND ?
AND client_name = ? AND description = ? AND seats_used = ?
AND link = ?;
""",
(room_id, start_date.isoformat(), end_date.isoformat(), client_name, description, seats_used, link)
)
conn.commit()
conn.close()
log.info("Удалено бронирование: room_id=%s, %s..%s, заказчик=%s, мест=%s",
room_id, start_date.isoformat(), end_date.isoformat(), client_name, seats_used)
@db_retry()
def can_add_booking_range(room, start_date, end_date, seats_used, skip_booking_range=None):
conn = get_connection()
cur = conn.cursor()
room_id = room["id"]
room_type = room["type"]
current = start_date
while current <= end_date:
date_str = current.isoformat()
if skip_booking_range:
skip_room, skip_start, skip_end, skip_client, skip_desc, skip_seats, skip_link = skip_booking_range
if not (room_id == skip_room and skip_start <= current <= skip_end):
cur.execute(
"SELECT COUNT(*) AS c FROM bookings WHERE room_id = ? AND date = ?;",
(room_id, date_str)
)
if cur.fetchone()["c"] > 0:
conn.close()
return False, f"Комната уже занята на {current.strftime('%d.%m.%Y')}."
else:
cur.execute(
"SELECT COUNT(*) AS c FROM bookings WHERE room_id = ? AND date = ?;",
(room_id, date_str)
)
if cur.fetchone()["c"] > 0:
conn.close()
return False, f"Комната уже занята на {current.strftime('%d.%m.%Y')}."
stats = get_day_stats(current)
if room_type == "vks":
used_rooms = stats["vks"]["rooms_used"]
used_seats = stats["vks"]["seats_used"]
if skip_booking_range and room_id == skip_room and skip_start <= current <= skip_end:
used_rooms -= 1
used_seats -= skip_seats
if used_rooms + 1 > MAX_VKS_ROOMS_PER_DAY:
conn.close()
return False, f"Превышен лимит комнат ВКС на {current.strftime('%d.%m.%Y')} (макс. {MAX_VKS_ROOMS_PER_DAY})."
if used_seats + seats_used > MAX_VKS_SEATS_PER_DAY:
conn.close()
return False, f"Превышен лимит участников ВКС на {current.strftime('%d.%m.%Y')} (макс. {MAX_VKS_SEATS_PER_DAY})."
else:
used_rooms = stats["webinar"]["rooms_used"]
used_seats = stats["webinar"]["seats_used"]
if skip_booking_range and room_id == skip_room and skip_start <= current <= skip_end:
used_rooms -= 1
used_seats -= skip_seats
if used_rooms + 1 > MAX_WEB_ROOMS_PER_DAY:
conn.close()
return False, f"Превышен лимит комнат вебинаров на {current.strftime('%d.%m.%Y')} (макс. {MAX_WEB_ROOMS_PER_DAY})."
if used_seats + seats_used > MAX_WEB_SEATS_PER_DAY:
conn.close()
return False, f"Превышен лимит участников вебинаров на {current.strftime('%d.%m.%Y')} (макс. {MAX_WEB_SEATS_PER_DAY})."
current += timedelta(days=1)
conn.close()
return True, "OK"
@db_retry()
def add_booking_range(room, start_date, end_date, client_name, description, seats_used, link=None,
time_open=None, time_close=None, doklad_link=None, moder_link=None,
event_id=None, contact_name=None, training=None, event_name=None,
has_foreign=0, foreign_guest_link=None, foreign_doklad_link=None,
foreign_moder_link=None, foreign_event_id=None, foreign_country=None):
ok, msg = can_add_booking_range(room, start_date, end_date, seats_used)
if not ok:
log.warning("Отказ при бронировании (%s, %s..%s): %s",
room["name"], start_date.isoformat(), end_date.isoformat(), msg)
return False, msg
conn = get_connection()
cur = conn.cursor()
room_id = room["id"]
current = start_date
while current <= end_date:
date_str = current.isoformat()
cur.execute(
"""
INSERT INTO bookings (room_id, date, client_name, description, seats_used, link,
time_open, time_close, doklad_link, moder_link, event_id, contact_name,
training, event_name,
has_foreign, foreign_guest_link, foreign_doklad_link,
foreign_moder_link, foreign_event_id, foreign_country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(room_id, date_str, client_name, description, seats_used, link,
time_open, time_close, doklad_link, moder_link, event_id, contact_name,
training, event_name,
has_foreign, foreign_guest_link, foreign_doklad_link,
foreign_moder_link, foreign_event_id, foreign_country)
)
current += timedelta(days=1)
conn.commit()
conn.close()
log.info("Добавлено бронирование: %s(%s), %s..%s, заказчик=%s, мест=%s",
room["name"], room["type"], start_date.isoformat(), end_date.isoformat(),
client_name, seats_used)
return True, "Бронирование добавлено."
@db_retry()
def delete_booking(booking_id):
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM bookings WHERE id = ?;", (booking_id,))
conn.commit()
conn.close()
# ---------- Генерация письма заказчику ----------
def _esc(s):
"""Экранирование для HTML."""
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
def build_email(room_name, room_type, start_date, end_date, seats,
guest_link=None, time_open=None, time_close=None,
doklad_link=None, moder_link=None, event_id=None, contact_name=None,
training=None, event_name=None):
"""Собирает письмо заказчику по шаблону. Возвращает (html, plain)."""
# Используем название обучения или мероприятия, если есть
event_title = training or event_name or room_name
# ========== СТИЛИ (цвета и размеры) ==========
# 14pt — основной размер текста в письме
BIC = '<span style="font-size:14pt; font-weight:bold; font-style:italic; color:#000000">'
# Красный жирный курсив с подчёркиванием (для "Прошу подтвердить")
RED_UL = '<span style="font-size:14pt; font-weight:bold; font-style:italic; color:#FF0000; text-decoration:underline">'
# Жёлтое выделение для заголовков блоков ссылок
YEL = '<span style="font-size:14pt; font-weight:bold; font-style:italic; color:#000000; background-color:yellow">'
# Обычный текст 14pt (для правил, инструкций)
PLAIN = '<span style="font-size:14pt">'
# Красный жирный подчёркнутый (для "ЗАПРЕЩЕНО")
RED_PLAIN = '<span style="font-size:14pt; color:#FF0000; font-weight:bold">'
def close():
return "</span>"
def div_open():
return "<div>"
def div_close():
return "</div>"
def br():
return "<div><br></div>" # пустая строка-отступ между блоками
# ========== СОДЕРЖИМОЕ ==========
greeting = f"{contact_name}, добрый день!" if contact_name else "Добрый день!"
if start_date == end_date:
date_str = start_date.strftime("%d.%m.%Y")
else:
date_str = f"{start_date.strftime('%d.%m.%Y')}-{end_date.strftime('%d.%m.%Y')}"
time_str = ""
if time_open and time_close:
time_str = f" с {time_open} до {time_close}"
elif time_open:
time_str = f" с {time_open}"
elif time_close:
time_str = f" до {time_close}"
type_room = "ВКС" if room_type == "vks" else "Вебинар"
event_line = f"На ({date_str}{time_str}) запланировано мероприятие - {type_room}, {event_title}"
seats_line = f"Количество участников - {seats}"
html = []
plain = []
# ---- 1. Приветствие ----
html.append(f'{div_open()}{BIC}{_esc(greeting)}{close()}{div_close()}')
plain.append(greeting)
html.append(br())
# ---- 2. Запрос подтверждения (красный подчёркнутый) ----
confirm_text = "Прошу подтвердить в ответном письме получение ссылок мероприятия!"
html.append(f'{div_open()}{RED_UL}{_esc(confirm_text)}{close()}{div_close()}')
plain.append(confirm_text)
html.append(br())
# ---- 3. Мероприятие ----
html.append(f'{div_open()}{BIC}{_esc(event_line)}{close()}{div_close()}')
plain.append(event_line)
html.append('<div><br></div>') # ПУСТАЯ СТРОКА
# ---- 4. Количество участников ----
seats_line = f"Количество участников - {seats}"
html.append(f'{div_open()}{BIC}{_esc(seats_line)}{close()}{div_close()}')
plain.append(seats_line)
html.append(br())
# ---- 5. Блоки ссылок (только если заполнены) ----
link_blocks = [
("Гостевая ссылка для подключения:", guest_link),
("Ссылка для подключения докладчиков (для загрузки материалов):", doklad_link),
("Ссылка для подключения модераторов:", moder_link),
]
for title, url in link_blocks:
if url:
# Заголовок с жёлтым выделением
html.append(f'{div_open()}{YEL}{_esc(title)}{close()}{div_close()}')
# Кликабельная ссылка — жирная, подчёркнутая
html.append(
f'{div_open()}<a href="{_esc(url)}" '
f'style="color:#000000; text-decoration:underline; font-size:14pt; font-weight:bold">'
f'{_esc(url)}</a>{div_close()}'
)
html.append(br())
plain.append(f"{title}\n{url}\n")
# ---- 6. ID мероприятия ----
if event_id:
html.append(f'{div_open()}{YEL}ID мероприятия для подключения:{close()}{div_close()}')
html.append(f'{div_open()}{BIC}{_esc(event_id)}{close()}{div_close()}')
html.append(br())
plain.append(f"ID мероприятия для подключения:\n{event_id}\n")
# ---- 7. Сайт для входа ----
login_url = "https://ivavks.rosatom.ru/#login_by_id"
html.append(f'{div_open()}{YEL}Сайт для входа:{close()}{div_close()}')
html.append(
f'{div_open()}<a href="{login_url}" '
f'style="color:#000000; text-decoration:underline; font-size:14pt; font-weight:bold">'
f'{login_url}</a>{div_close()}'
)
html.append(br())
plain.append(f"Сайт для входа:\n{login_url}\n")
# ---- 8. Инструкции и материалы ----
guides_url = "https://greenatom.ru/atomvks/#guides"
html.append(f'{div_open()}{YEL}Инструкции и материалы:{close()}{div_close()}')
html.append(
f'{div_open()}<a href="{guides_url}" '
f'style="color:#954F72; text-decoration:underline; font-size:14pt; font-weight:bold">'
f'{guides_url}</a>{div_close()}'
)
html.append(br())
plain.append(f"Инструкции и материалы:\n{guides_url}\n")
# ====== СТАТИЧЕСКИЙ БЛОК: ПРАВИЛА И ИНСТРУКЦИИ ======
# ---- Правила для вебинаров ----
html.append(f'{div_open()}{PLAIN}В мероприятиях типа «<b>ВЕБИНАР</b>» кол-во «активных» '
f'докладчиков не должно превышать <b>5 человек</b>, а общее кол-во докладчиков '
f'<b>10 человек</b>.{close()}{div_close()}')
html.append(br())
plain.append('В мероприятиях типа «ВЕБИНАР» кол-во «активных» докладчиков не должно '
'превышать 5 человек, а общее кол-во докладчиков 10 человек.\n')
# ---- Блок про запись ----
html.append(f'{div_open()}{PLAIN}<b>ЗАПИСЬ МЕРОПРИЯТИЯ</b> это зона ответственности '
f'<b>заказчика</b> мероприятия.{close()}{div_close()}')
html.append(f'{div_open()}{RED_UL}Запись не включается автоматически!{close()}{div_close()}')
html.append(f'{div_open()}{PLAIN}Включение или остановка записи возможно только при входе '
f'в мероприятие по <b>«Ссылка для подключения модераторов»</b>.{close()}{div_close()}')
html.append(f'{div_open()}{PLAIN}Для включения и остановки записи нужно нажать кнопку '
f'<b>«Три точки»</b>, затем кнопку <b>«Включить запись мероприятия/'
f'Выключить запись мероприятия»</b>.{close()}{div_close()}')
html.append(f'{div_open()}{PLAIN}Запись и все загруженные материалы хранятся в комнате '
f'<b><u>7 календарных дней</u></b>, далее автоматически удаляются с платформы '
f'без возможности восстановления.{close()}{div_close()}')
html.append(f'{div_open()}{PLAIN}Хранение, распространение и опубликование записи '
f'мероприятия - это зона ответственности заказчика мероприятия с привлечением '
f'ресурсов организатора.{close()}{div_close()}')
html.append(br())
plain.append("ЗАПИСЬ МЕРОПРИЯТИЯ это зона ответственности заказчика мероприятия.\n"
"Запись не включается автоматически!\n"
"Включение или остановка записи возможно только при входе в мероприятие "
"по «Ссылка для подключения модераторов».\n"
"Для включения и остановки записи нужно нажать кнопку «Три точки», "
"затем кнопку «Включить запись мероприятия/Выключить запись мероприятия».\n"
"Запись и все загруженные материалы хранятся в комнате 7 календарных дней, "
"далее автоматически удаляются с платформы без возможности восстановления.\n"
"Хранение, распространение и опубликование записи мероприятия - это зона "
"ответственности заказчика мероприятия с привлечением ресурсов организатора.\n")
# ---- Блок про доступ из-за пределов РФ ----
html.append(f'{div_open()}{PLAIN}<b>Прямой доступ к платформе Atom ВКС из-за пределов РФ закрыт.</b>{close()}{div_close()}')
html.append(br())
html.append(f'{div_open()}{PLAIN} Доступ осуществляется через Атом ВКС находящейся в сегменте СБИС МБ.</b>{close()}{div_close()}')
html.append(f'{div_open()}{PLAIN}Для участия пользователей в мероприятиях из-за пределов РФ, необходимо подать '
f'другое обращения на создание дополнительной комнаты Атом ВКС в сегменте СБИС МБ. '
f'Для участников из-за границы будут предоставлены свои ссылки и ID мероприятия. '
f'Оба мероприятия (комнаты) будут соединены в одну. {close()}{div_close()}')
html.append(br())
plain.append("Прямой доступ к платформе Atom ВКС из-за пределов РФ закрыт.\n"
"Доступ осуществляется через Атом ВКС находящейся в сегменте СБИС МБ.\n\n"
"Для участия пользователей в мероприятиях из-за пределов РФ, необходимо подать "
"другое обращения на создание дополнительной комнаты Атом ВКС в сегменте СБИС МБ. "
"Для участников из-за границы будут предоставлены свои ссылки и ID мероприятия.\n"
"Оба мероприятия (комнаты) будут соединены в одну.\n\n")
# ---- Блок "ЗАПРЕЩЕНО" ----
html.append(f'{div_open()}{RED_PLAIN}При проведении аудио- и видеоконференций '
f'<u>ЗАПРЕЩЕНО</u>{close()}{div_close()}')
html.append(f'{div_open()}{RED_PLAIN}обрабатывать/обсуждать/разглашать информацию '
f'ограниченного доступа, отнесенной к:{close()}{div_close()}')
html.append(f'{div_open()}{RED_PLAIN} - коммерческой тайне,{close()}{div_close()}')
html.append(f'{div_open()}{RED_PLAIN} - информации, составляющей служебную '
f'тайну «Для служебного пользования» (ДСП),{close()}{div_close()}')
html.append(f'{div_open()}{RED_PLAIN} - государственной тайне{close()}{div_close()}')
plain.append("При проведении аудио- и видеоконференций ЗАПРЕЩЕНО\n"
"обрабатывать/обсуждать/разглашать информацию ограниченного доступа, "
"отнесенной к:\n"
" - коммерческой тайне,\n"
" - информации, составляющей служебную тайну «Для служебного пользования» (ДСП),\n"
" - государственной тайне")
return "\n".join(html), "\n".join(plain)
def copy_email_to_clipboard(html, plain):
"""Кладёт в буфер и HTML (для Outlook), и текст (для блокнота)."""
mime = QtCore.QMimeData()
mime.setHtml(html)
mime.setText(plain)
QtWidgets.QApplication.clipboard().setMimeData(mime)
# ---------- Диалог ----------
class EventIdEdit(QtWidgets.QLineEdit):
"""Поле ID мероприятия: принимает только цифры, сам ставит дефис после 3-й.
Итоговый формат: XXX-XX (5 цифр + дефис)."""
def __init__(self, parent=None):
super().__init__(parent)
self.setPlaceholderText("Формат: XXX-XX")
self._updating = False
self.textChanged.connect(self._on_text_changed)
def _on_text_changed(self, text):
if self._updating:
return
self._updating = True
# Оставляем только цифры, максимум 5
digits = "".join(ch for ch in text if ch.isdigit())[:5]
# Вставляем дефис после 3-й цифры
if len(digits) > 3:
formatted = digits[:3] + "-" + digits[3:]
else:
formatted = digits
if formatted != text:
self.setText(formatted)
self.setCursorPosition(len(formatted))
self._updating = False
class ForeignDialog(QtWidgets.QDialog):
"""Диалог для зарубежных слушателей: страна/организация, письмо, ссылки."""
def __init__(self, room, data, parent=None, view_only=False):
super().__init__(parent)
self.room = room
self.data = data
self.view_only = view_only
self.setWindowTitle("Зарубежные слушатели — просмотр" if view_only else "Зарубежные слушатели")
self.setMinimumWidth(500)
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
form = QtWidgets.QFormLayout()
form.setSpacing(10)
layout.addLayout(form)
# Страна/Организация
self.country_edit = QtWidgets.QLineEdit()
self.country_edit.setText(data.get("foreign_country") or "")
form.addRow("Страна/Организация:", self.country_edit)
# Кнопка создания письма (доступна всегда — письмо можно создать и из просмотра)
self.create_email_button = QtWidgets.QPushButton(" Создать письмо для запроса ссылок")
self.create_email_button.setIcon(make_icon("mail", "#ffffff"))
self.create_email_button.clicked.connect(self.on_create_email)
layout.addWidget(self.create_email_button)
# Разделитель
layout.addWidget(QtWidgets.QLabel("<hr>"))
# Поля ссылок (с кнопками копирования)
self.guest_edit = QtWidgets.QLineEdit()
self.guest_edit.setText(data.get("foreign_guest_link") or "")
form.addRow("Гостевая ссылка (зарубеж.):", field_with_copy(self.guest_edit))
self.doklad_edit = QtWidgets.QLineEdit()
self.doklad_edit.setText(data.get("foreign_doklad_link") or "")
form.addRow("Ссылка докладчиков (зарубеж.):", field_with_copy(self.doklad_edit))
self.moder_edit = QtWidgets.QLineEdit()
self.moder_edit.setText(data.get("foreign_moder_link") or "")
form.addRow("Ссылка модераторов (зарубеж.):", field_with_copy(self.moder_edit))
self.event_id_edit = EventIdEdit()
self.event_id_edit.setText(data.get("foreign_event_id") or "")
form.addRow("ID мероприятия (зарубеж.):", field_with_copy(self.event_id_edit))
# Кнопки
self.buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
)
self.ok_button = self.buttons.button(QtWidgets.QDialogButtonBox.Ok)
self.cancel_button = self.buttons.button(QtWidgets.QDialogButtonBox.Cancel)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
self._input_widgets = [
self.country_edit, self.guest_edit, self.doklad_edit,
self.moder_edit, self.event_id_edit,
]
self.set_view_mode(view_only)
def set_view_mode(self, view_only):
"""Включает/выключает режим «только просмотр» (копирование и письмо остаются доступны)."""
self.view_only = view_only
for w in self._input_widgets:
w.setEnabled(not view_only)
self.ok_button.setVisible(not view_only)
self.cancel_button.setText("Закрыть" if view_only else "Отмена")
self.ok_button.setDefault(not view_only)
self.cancel_button.setDefault(view_only)
self.setWindowTitle("Зарубежные слушатели — просмотр" if view_only else "Зарубежные слушатели")
def accept(self):
if self.view_only:
self.reject()
return
super().accept()
def on_create_email(self):
"""Создаёт письмо для запроса зарубежных ссылок и открывает Evolution."""
country = self.country_edit.text().strip()
if not country:
QtWidgets.QMessageBox.warning(
self, "Ошибка",
"Укажите страну/организацию перед созданием письма."
)
return
# Формируем данные
event_title = self.data["training"] or self.data["event_name"] or self.room["name"]
# Даты
if self.data["date_from"] == self.data["date_to"]:
date_str = self.data["date_from"].strftime("%d.%m.%Y")
else:
date_str = f"{self.data['date_from'].strftime('%d.%m.%Y')}-{self.data['date_to'].strftime('%d.%m.%Y')}"
# Время
if self.data["time_open"] and self.data["time_close"]:
time_str = f" с {self.data['time_open']} до {self.data['time_close']}"
elif self.data["time_open"]:
time_str = f" с {self.data['time_open']}"
elif self.data["time_close"]:
time_str = f" до {self.data['time_close']}"
else:
time_str = ""
moder_link = self.data["moder_link"] or ""
event_id = self.data["event_id"] or ""
# Шаблон письма
body = (
f"Добрый день.\n\n"
f"Просьба создать дополнительную комнату на платформе Атом ВКС в сегменте СБИС МБ, "
f"для подключения пользователей из-за пределов РФ - {country}\n\n"
f"Тема: {event_title}\n\n"
f"Период проведения обучения: {date_str}{time_str}\n\n"
f"Ссылка модератора: {moder_link}\n\n"
f"ID: {event_id}\n\n"
f"Спасибо."
)
subject = "Создание комнаты в сегменте СБИС МБ для подключения обучаемых, находящихся за рубежом, на платформе АТОМ ВКС"
log.info("Создано письмо запроса зарубежных ссылок: Кому=%s, страна=%s",
FOREIGN_EMAIL_TO, country)
# Копируем в буфер
cc = FOREIGN_EMAIL_CC.strip()
cc_line = f"Копия: {cc}\n" if (cc and "ZAGLUSHKA" not in cc.upper()) else ""
full_text = f"Кому: {FOREIGN_EMAIL_TO}\n{cc_line}Тема: {subject}\n\n{body}"
QtWidgets.QApplication.clipboard().setText(full_text)
# Открываем почтовый клиент с mailto (если установлен)
# «Кому» и «Копия» подставляются автоматически из констант в начале файла
import urllib.parse
mailto_url = (
f"mailto:{urllib.parse.quote(FOREIGN_EMAIL_TO)}"
f"?subject={urllib.parse.quote(subject)}"
f"&body={urllib.parse.quote(body)}"
)
# «Копия» добавляется только если заглушка заменена на реальный адрес
if cc and "ZAGLUSHKA" not in cc.upper():
mailto_url += f"&cc={urllib.parse.quote(cc)}"
try:
QDesktopServices.openUrl(QUrl(mailto_url))
QtWidgets.QMessageBox.information(
self, "Готово",
"Письмо скопировано в буфер обмена и открыто в почтовом клиенте.\n"
"Если почтовый клиент не открылся, вставьте текст вручную (Ctrl+V)."
)
except Exception:
QtWidgets.QMessageBox.information(
self, "Готово",
"Письмо скопировано в буфер обмена.\n"
"Вставьте его в почтовый клиент вручную (Ctrl+V)."
)
def get_data(self):
"""Возвращает данные диалога."""
return {
"foreign_country": self.country_edit.text().strip() or None,
"foreign_guest_link": self.guest_edit.text().strip() or None,
"foreign_doklad_link": self.doklad_edit.text().strip() or None,
"foreign_moder_link": self.moder_edit.text().strip() or None,
"foreign_event_id": self.event_id_edit.text().strip() or None,
"has_foreign": 1 if any([
self.country_edit.text().strip(),
self.guest_edit.text().strip(),
self.doklad_edit.text().strip(),
self.moder_edit.text().strip(),
self.event_id_edit.text().strip(),
]) else 0,
}
class BookingDialog(QtWidgets.QDialog):
def __init__(self, room, default_start, default_end, max_seats_available=None, parent=None, is_edit=False, view_only=False):
super().__init__(parent)
self.max_seats_available = max_seats_available
self.is_edit = is_edit
self.view_only = view_only
self.default_start = default_start
self.default_end = default_end
self.setWindowTitle("Редактирование бронирования" if is_edit else "Новое бронирование")
self.setMinimumWidth(400)
self.room = room
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# >>> ШАПКА: статус-метка слева + кнопка разблокировки редактирования справа <<<
header = QtWidgets.QHBoxLayout()
self.mode_label = QtWidgets.QLabel("")
self.mode_label.setObjectName("modeLabel")
self.unlock_button = QtWidgets.QToolButton()
self.unlock_button.setText(" Редактировать")
self.unlock_button.setIcon(make_icon("unlock", "#ffffff"))
self.unlock_button.setObjectName("unlockButton")
self.unlock_button.setCursor(Qt.PointingHandCursor)
self.unlock_button.setToolTip("Разблокировать поля для редактирования")
self.unlock_button.clicked.connect(self.on_unlock_edit)
header.addWidget(self.mode_label)
header.addStretch()
header.addWidget(self.unlock_button)
layout.addLayout(header)
form = QtWidgets.QFormLayout()
form.setSpacing(10)
layout.addLayout(form)
# Сначала создаём виджеты дат
self.date_from = QtWidgets.QDateEdit()
self.date_from.setCalendarPopup(True)
self.date_from.setDate(QtCore.QDate(default_start.year, default_start.month, default_start.day))
self.date_to = QtWidgets.QDateEdit()
self.date_to.setCalendarPopup(True)
self.date_to.setDate(QtCore.QDate(default_end.year, default_end.month, default_end.day))
# >>> ВЫБОР КОМНАТЫ: свободные для нового, все для редактирования <<<
self.room_combo = QtWidgets.QComboBox()
if is_edit:
# Для редактирования — все комнаты (выбор всё равно заблокирован)
self.all_rooms = fetch_rooms()
for r in self.all_rooms:
self.room_combo.addItem(r["name"], r["id"])
for i in range(self.room_combo.count()):
if self.room_combo.itemData(i) == room["id"]:
self.room_combo.setCurrentIndex(i)
break
self.room_combo.setEnabled(False)
self.room_combo.setToolTip("Комнату нельзя менять при редактировании бронирования")
else:
# Для нового бронирования — только свободные на выбранные даты
self.all_rooms = []
self._refresh_room_list()
self.room_combo.currentIndexChanged.connect(self.on_room_changed)
# Синхронизация дат и обновление списка комнат при смене дат
self.date_from.dateChanged.connect(self.on_date_from_changed)
self.date_from.dateChanged.connect(self._refresh_room_list)
self.date_to.dateChanged.connect(self._refresh_room_list)
self.client_edit = QtWidgets.QLineEdit()
self.client_edit.editingFinished.connect(self.on_client_name_changed)
self.training_edit = QtWidgets.QLineEdit()
self.event_name_edit = QtWidgets.QLineEdit()
# Автозамена " " на « »
self.training_edit.textChanged.connect(lambda t: self._auto_replace_quotes(self.training_edit))
self.event_name_edit.textChanged.connect(lambda t: self._auto_replace_quotes(self.event_name_edit))
self.seats_spin = QtWidgets.QSpinBox()
self.seats_spin.setMinimum(1)
if self.max_seats_available is not None and self.max_seats_available > 0:
self.seats_spin.setMaximum(self.max_seats_available)
else:
self.seats_spin.setMaximum(10000)
self.seats_spin.setValue(1)
self.max_button = QtWidgets.QPushButton("Макс.")
self.max_button.clicked.connect(self.on_set_max_seats)
self.max_button.setStyleSheet("padding: 6px; font-size: 12px;")
seats_layout = QtWidgets.QHBoxLayout()
seats_layout.addWidget(self.seats_spin)
seats_layout.addWidget(self.max_button)
seats_widget = QtWidgets.QWidget()
seats_widget.setLayout(seats_layout)
self.link_edit = QtWidgets.QLineEdit()
# >>> НОВЫЕ ПОЛЯ ДЛЯ ПИСЬМА <<<
self.contact_edit = QtWidgets.QLineEdit()
self.contact_edit.setPlaceholderText("Если пусто — будет просто «Добрый день!»")
self.time_check = QtWidgets.QCheckBox("Указать время")
self.time_check.setChecked(False)
self.time_open = QtWidgets.QTimeEdit()
self.time_open.setDisplayFormat("HH:mm")
self.time_close = QtWidgets.QTimeEdit()
self.time_close.setDisplayFormat("HH:mm")
self.time_open.setEnabled(False)
self.time_close.setEnabled(False)
self.time_check.toggled.connect(self.on_time_toggled)
time_layout = QtWidgets.QHBoxLayout()
time_layout.addWidget(self.time_check)
time_layout.addWidget(self.time_open)
time_layout.addWidget(QtWidgets.QLabel("—"))
time_layout.addWidget(self.time_close)
time_widget = QtWidgets.QWidget()
time_widget.setLayout(time_layout)
self.doklad_edit = QtWidgets.QLineEdit()
self.moder_edit = QtWidgets.QLineEdit()
self.event_id_edit = EventIdEdit()
# >>> ЗАРУБЕЖНЫЕ СЛУШАТЕЛИ: отдельный диалог <<<
self.foreign_button = QtWidgets.QPushButton(" Зарубежные слушатели")
self.foreign_button.setIcon(make_icon("globe", "#ffffff"))
self.foreign_button.clicked.connect(self.on_foreign_clicked)
layout.addWidget(self.foreign_button)
# Данные зарубежных слушателей (хранятся в основном диалоге)
self.foreign_data = {
"has_foreign": 0,
"foreign_country": None,
"foreign_guest_link": None,
"foreign_doklad_link": None,
"foreign_moder_link": None,
"foreign_event_id": None,
}
form.addRow("Комната:", self.room_combo)
form.addRow("Дата с:", self.date_from)
form.addRow("Дата по:", self.date_to)
form.addRow("Время:", time_widget)
form.addRow("ФИО заказчика:", self.client_edit)
form.addRow("Имя для обращения:", self.contact_edit)
form.addRow("Обучение:", field_with_copy(self.training_edit))
form.addRow("Мероприятие:", field_with_copy(self.event_name_edit))
form.addRow("Кол-во участников:", seats_widget)
form.addRow("Гостевая ссылка:", field_with_copy(self.link_edit))
form.addRow("Ссылка докладчиков:", field_with_copy(self.doklad_edit))
form.addRow("Ссылка модераторов:", field_with_copy(self.moder_edit))
form.addRow("ID мероприятия:", field_with_copy(self.event_id_edit))
self.atom_button = QtWidgets.QPushButton(" Копировать название для АТОМ ВКС")
self.atom_button.setIcon(make_icon("copy", "#ffffff"))
self.atom_button.clicked.connect(self.on_generate_atom_name)
layout.addWidget(self.atom_button)
self.mail_button = QtWidgets.QPushButton(" Копировать письмо")
self.mail_button.setIcon(make_icon("mail", "#ffffff"))
self.mail_button.clicked.connect(self.on_generate_email)
layout.addWidget(self.mail_button)
# Кнопки: Сохранить/Забронировать (Ok) + Закрыть/Отмена (Cancel)
self.buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
)
self.ok_button = self.buttons.button(QtWidgets.QDialogButtonBox.Ok)
self.ok_button.setText("Сохранить" if is_edit else "Забронировать")
self.cancel_button = self.buttons.button(QtWidgets.QDialogButtonBox.Cancel)
self.cancel_button.setText("Отмена")
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
# Все редактируемые поля — для массового включения/выключения в режиме просмотра
# (кнопка зарубежных слушателей остаётся доступной всегда — для просмотра/копирования)
self._input_widgets = [
self.date_from, self.date_to, self.time_check, self.time_open, self.time_close,
self.client_edit, self.contact_edit, self.training_edit, self.event_name_edit,
self.seats_spin, self.max_button, self.link_edit, self.doklad_edit,
self.moder_edit, self.event_id_edit,
]
self.set_view_mode(view_only)
def on_unlock_edit(self):
"""Разблокирует все поля для редактирования из режима просмотра."""
self.set_view_mode(False)
def set_view_mode(self, view_only):
"""Включает/выключает режим «только просмотр»."""
self.view_only = view_only
for w in self._input_widgets:
# Комнату при редактировании менять нельзя в любом случае
if w is getattr(self, "room_combo", None):
continue
w.setEnabled(not view_only)
# Поля времени активны только если стоит галочка и не режим просмотра
if not view_only:
self.on_time_toggled(self.time_check.isChecked())
# Комната: для нового бронирования доступна при редактировании полей
if hasattr(self, "room_combo"):
self.room_combo.setEnabled(not view_only and not self.is_edit)
self.ok_button.setVisible(not view_only)
self.cancel_button.setText("Закрыть" if view_only else "Отмена")
self.unlock_button.setVisible(view_only)
self.ok_button.setDefault(not view_only)
self.cancel_button.setDefault(view_only)
# Копирование названия и письма доступно всегда (даже в просмотре)
self.atom_button.setEnabled(True)
self.mail_button.setEnabled(True)
# Зарубежные слушатели: просмотр/копирование доступны всегда
self.foreign_button.setEnabled(True)
if view_only:
self.setWindowTitle("Просмотр бронирования")
self.mode_label.setText("Режим просмотра — поля заблокированы")
else:
self.setWindowTitle("Редактирование бронирования" if self.is_edit else "Новое бронирование")
self.mode_label.setText("")
def _get_range(self):
qdf = self.date_from.date()
qdt = self.date_to.date()
d_from = date(qdf.year(), qdf.month(), qdf.day())
d_to = date(qdt.year(), qdt.month(), qdt.day())
if d_to < d_from:
d_from, d_to = d_to, d_from
return d_from, d_to
def on_date_from_changed(self, qdate):
# Если дата "по" раньше даты "с" — подтягиваем её
if self.date_to.date() < qdate:
self.date_to.setDate(qdate)
def _refresh_room_list(self):
if self.is_edit:
return
current_room_id = self.room_combo.currentData()
start, end = self._get_range()
free_rooms = fetch_free_rooms(start, end)
self.room_combo.blockSignals(True)
self.room_combo.clear()
for r in free_rooms:
self.room_combo.addItem(r["name"], r["id"])
self.room_combo.blockSignals(False)
# Восстанавливаем выбор, если комната ещё свободна
for i in range(self.room_combo.count()):
if self.room_combo.itemData(i) == current_room_id:
self.room_combo.setCurrentIndex(i)
return
if self.room_combo.count() > 0:
self.room_combo.setCurrentIndex(0)
def on_generate_email(self):
room = self.current_room()
data = self.get_data()
html, plain = build_email(
room_name=room["name"], room_type=room["type"],
start_date=data["date_from"], end_date=data["date_to"],
seats=data["seats_used"], guest_link=data["link"],
time_open=data["time_open"], time_close=data["time_close"],
doklad_link=data["doklad_link"], moder_link=data["moder_link"],
event_id=data["event_id"], contact_name=data["contact_name"],
training=data["training"], event_name=data["event_name"],
)
copy_email_to_clipboard(html, plain)
# Уведомляем через статусбар главного окна
if self.parent() and hasattr(self.parent(), "copyStatusLabel"):
self.parent().copyStatusLabel.setText("✓ Письмо скопировано в буфер обмена")
QtCore.QTimer.singleShot(3000, lambda: self.parent().copyStatusLabel.setText(""))
def on_generate_atom_name(self):
"""Генерирует простое название для АТОМ ВКС и копирует в буфер."""
room = self.current_room()
data = self.get_data()
type_room = "ВКС" if room["type"] == "vks" else "Вебинар"
# Дата
if data["date_from"] == data["date_to"]:
date_str = data["date_from"].strftime("%d.%m.%Y")
else:
date_str = f"{data['date_from'].strftime('%d.%m.%Y')}-{data['date_to'].strftime('%d.%m.%Y')}"
# Время
if data["time_open"] and data["time_close"]:
time_str = f" с {data['time_open']} до {data['time_close']}"
elif data["time_open"]:
time_str = f" с {data['time_open']}"
elif data["time_close"]:
time_str = f" до {data['time_close']}"
else:
time_str = ""
# Название
event_title = data["training"] or data["event_name"] or room["name"]
# Количество участников
seats = data["seats_used"]
seats_word = plural_uchastnik(seats)
# Собираем строку
atom_name = f"{type_room}, {date_str}{time_str}, {event_title}, {seats} {seats_word}"
# Копируем в буфер
QtWidgets.QApplication.clipboard().setText(atom_name)
# Уведомляем через статусбар
if self.parent() and hasattr(self.parent(), "copyStatusLabel"):
self.parent().copyStatusLabel.setText("✓ Название скопировано в буфер обмена")
QtCore.QTimer.singleShot(3000, lambda: self.parent().copyStatusLabel.setText(""))
def current_room(self):
"""Возвращает комнату, выбранную в списке."""
rid = self.room_combo.currentData()
for r in self.all_rooms:
if r["id"] == rid:
return r
return self.room
def on_room_changed(self, index):
"""При смене комнаты в новом бронировании пересчитываем лимит участников."""
if self.is_edit:
return
parent = self.parent()
if parent is None or not hasattr(parent, "calculate_max_seats_for_range"):
return
room = self.current_room()
if room is None:
return
new_max = parent.calculate_max_seats_for_range(
room, self.default_start, self.default_end, existing_seats=0
)
self.max_seats_available = new_max
self.seats_spin.setMaximum(max(new_max, 1))
if self.seats_spin.value() > max(new_max, 1):
self.seats_spin.setValue(max(new_max, 1))
def accept(self):
if self.view_only:
# В режиме просмотра сохранять нельзя — просто закрываем
self.reject()
return
if not self.client_edit.text().strip():
QtWidgets.QMessageBox.warning(self, "Ошибка", "Нужно указать ФИО заказчика.")
return
if self.max_seats_available is not None and self.max_seats_available > 0:
if self.seats_spin.value() > self.max_seats_available:
self.seats_spin.setValue(self.max_seats_available)
super().accept()
def get_data(self):
d_from, d_to = self._get_range()
data = {
"room": self.current_room(),
"date_from": d_from,
"date_to": d_to,
"client_name": self.client_edit.text().strip(),
"description": "",
"seats_used": self.seats_spin.value(),
"link": self.link_edit.text().strip() or None,
"time_open": self.time_open.time().toString("HH:mm") if self.time_check.isChecked() else None,
"time_close": self.time_close.time().toString("HH:mm") if self.time_check.isChecked() else None,
"doklad_link": self.doklad_edit.text().strip() or None,
"moder_link": self.moder_edit.text().strip() or None,
"event_id": self.event_id_edit.text().strip() or None,
"contact_name": self.contact_edit.text().strip() or None,
"training": self.training_edit.text().strip() or None,
"event_name": self.event_name_edit.text().strip() or None,
}
# Добавляем данные зарубежных слушателей
data.update(self.foreign_data)
return data
def on_set_max_seats(self):
if self.max_seats_available is not None and self.max_seats_available > 0:
self.seats_spin.setValue(self.max_seats_available)
def on_time_toggled(self, on):
self.time_open.setEnabled(on)
self.time_close.setEnabled(on)
def on_client_name_changed(self):
"""Автоматически подставляет имя для обращения по фамилии из уже существующих бронирований."""
client_text = self.client_edit.text().strip()
if not client_text:
return
# Берём фамилию — первое слово до пробела
last_name = client_text.split()[0]
if not last_name:
return
# Если поле контактного имени пустое — пробуем найти
if not self.contact_edit.text().strip():
found_name = find_contact_name_by_last_name(last_name)
if found_name:
self.contact_edit.setText(found_name)
def _auto_replace_quotes(self, edit_widget):
"""Заменяет " " на « » с правильным определением открывающих/закрывающих."""
if hasattr(edit_widget, '_replacing') and edit_widget._replacing:
return
edit_widget._replacing = True
try:
text = edit_widget.text()
cursor_pos = edit_widget.cursorPosition()
if '"' not in text:
edit_widget._replacing = False
return
# Определяем, какие кавычки открывающие, какие закрывающие
new_text = ""
for i, ch in enumerate(text):
if ch == '"':
# Если в начале строки или перед кавычкой пробел/пунктуация — это открывающая
if i == 0 or text[i-1] in ' \t\n\r([{-—–':
new_text += '«'
else:
new_text += '»'
else:
new_text += ch
if new_text != text:
edit_widget.setText(new_text)
# Восстанавливаем позицию курсора
edit_widget.setCursorPosition(cursor_pos)
finally:
edit_widget._replacing = False
def on_foreign_clicked(self):
"""Открывает диалог зарубежных слушателей (в режиме просмотра — только чтение/копирование)."""
# Передаём текущие данные
current_data = self.get_data()
current_data.update(self.foreign_data)
# Общая кнопка «Редактировать» в этом диалоге разблокирует и зарубежные ссылки:
# пока диалог в режиме просмотра — зарубежный диалог тоже только для чтения
dlg = ForeignDialog(self.current_room(), current_data, parent=self, view_only=self.view_only)
if dlg.exec_() == QtWidgets.QDialog.Accepted:
self.foreign_data = dlg.get_data()
# ---------- Стили и темы ----------
# Снимок светлых цветов ячеек (то, что задано в COLORS выше) — источник для светлой темы
COLORS_LIGHT = dict(COLORS)
# Тёмные цвета ячеек таблицы
COLORS_DARK = {
"header": "#243a55",
"header_weekend": "#5b3040",
"header_selected": "#2f4a63",
"header_today": "#4a4426",
"vks_booked": "#1e3a5f",
"webinar_booked": "#14453f",
"free_vks": "#16283c",
"free_webinar": "#13302b",
"free_weekend": "#2a2419",
"monday_line": "#4a6480",
"row_vks": "#23456b",
"row_webinar": "#1d544b",
"booked_no_link": "#3a4450",
"free_no_licenses": "#7f2d2d",
"free_no_licenses_text": "#ffffff",
"search_dim": "#1b2532",
"search_match_border": "#f2994a",
"grid_line": "#2b4059",
}
# Цвет текста в заголовках таблицы (переключается темой)
HEADER_TEXT_COLOR = "#22303f"
# ====== Палитры интерфейса: бирюза + синий ======
PALETTE_LIGHT = {
"window_bg": "#eef3f8", "panel_bg": "#ffffff", "panel_alt": "#f2f6fa",
"field_bg": "#ffffff",
"text": "#1f2d3d", "text_muted": "#64748b",
"border": "#dbe4ee", "border_strong": "#bcccdc",
"primary": "#2f80ed", "primary_hover": "#1c6cd6", "primary_pressed": "#1557b0",
"teal": "#12b3a6", "teal_hover": "#0e9c90",
"danger": "#eb5757", "danger_hover": "#d64545",
"neutral_bg": "#eef2f6", "neutral_bg_hover": "#e2e8f0",
"neutral_text": "#334e68", "neutral_border": "#cbd5e1",
"selection_bg": "#2f80ed",
"statusbar_bg": "#12324f", "statusbar_text": "#e6f0f7", "statusbar_accent": "#22c9b6",
"progress_bg": "rgba(255,255,255,0.15)", "progress_text": "#eaf6ff",
"toolbtn_bg": "rgba(255,255,255,0.92)", "toolbtn_bg_hover": "#ffffff",
"header_text": "#22303f",
}
PALETTE_DARK = {
"window_bg": "#0b1626", "panel_bg": "#17293f", "panel_alt": "#1f3550",
"field_bg": "#0b1727",
"text": "#eaf1f8", "text_muted": "#9db0c6",
"border": "#2c4260", "border_strong": "#41608a",
"primary": "#3b82f6", "primary_hover": "#2f6fe0", "primary_pressed": "#2559b8",
"teal": "#22c9b6", "teal_hover": "#17b3a1",
"danger": "#f87171", "danger_hover": "#ef4444",
"neutral_bg": "#1f3550", "neutral_bg_hover": "#2a4463",
"neutral_text": "#dde9f5", "neutral_border": "#41608a",
"selection_bg": "#2f6fe0",
"statusbar_bg": "#081120", "statusbar_text": "#dff3ef", "statusbar_accent": "#22c9b6",
"progress_bg": "rgba(255,255,255,0.12)", "progress_text": "#dff3ef",
"toolbtn_bg": "#1f3550", "toolbtn_bg_hover": "#2a4463",
"header_text": "#dbe7f3",
}
CHECK_B64 = ("PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRw"
"Oi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEgNEw0LjUgNy41TDExIDEiIHN0cm9rZT0id2hpdGUi"
"IHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+"
"PC9zdmc+")
_QSS_TEMPLATE = Template("""
QMainWindow { background-color: ${window_bg}; }
QWidget#centralWidget { background-color: ${window_bg}; }
QLabel { color: ${text}; }
QTableWidget {
background-color: ${panel_bg};
border: 1px solid ${border}; border-radius: 8px;
gridline-color: ${border}; font-size: 13px; color: ${text};
}
QTableWidget::item { padding: 8px; }
QTableWidget::item:selected { background-color: ${selection_bg}; color: white; }
QTableWidget::item:focus { outline: none; border: none; }
QPushButton {
background-color: ${primary}; color: white; border: none;
padding: 8px 16px; border-radius: 8px; font-weight: bold; font-size: 13px;
}
QPushButton:hover { background-color: ${primary_hover}; }
QPushButton:pressed { background-color: ${primary_pressed}; }
QPushButton:disabled { background-color: ${border}; color: ${text_muted}; }
QPushButton#btnRefresh { background-color: ${teal}; }
QPushButton#btnRefresh:hover { background-color: ${teal_hover}; }
QPushButton#btnAdd { background-color: ${primary}; }
QPushButton#btnAdd:hover { background-color: ${primary_hover}; }
QPushButton#btnDeleteTop { background-color: ${danger}; }
QPushButton#btnDeleteTop:hover { background-color: ${danger_hover}; }
QPushButton#btnExport { background-color: ${teal}; }
QPushButton#btnExport:hover { background-color: ${teal_hover}; }
QPushButton#btnPrev, QPushButton#btnNext, QPushButton#btnToday, QPushButton#btnEditTop {
background-color: ${neutral_bg}; color: ${neutral_text}; border: 1px solid ${neutral_border};
}
QPushButton#btnPrev:hover, QPushButton#btnNext:hover, QPushButton#btnToday:hover, QPushButton#btnEditTop:hover {
background-color: ${neutral_bg_hover};
}
QCheckBox { font-size: 13px; color: ${text}; spacing: 6px; }
QCheckBox::indicator {
width: 18px; height: 18px; border: 2px solid ${border_strong};
border-radius: 5px; background-color: ${field_bg};
}
QCheckBox::indicator:checked {
background-color: ${teal}; border-color: ${teal};
image: url(data:image/svg+xml;base64,""" + CHECK_B64 + """);
}
QComboBox, QDateEdit, QTimeEdit {
padding: 6px 12px; border: 1px solid ${border_strong}; border-radius: 8px;
background-color: ${field_bg}; color: ${text}; font-size: 13px; min-height: 20px;
}
QComboBox::drop-down { border: none; width: 20px; }
QComboBox QAbstractItemView {
background-color: ${field_bg}; color: ${text};
selection-background-color: ${selection_bg}; selection-color: white;
border: 1px solid ${border_strong};
}
QCalendarWidget { background-color: ${panel_bg}; min-width: 280px; }
QCalendarWidget QToolButton#qt_calendar_monthbutton,
QCalendarWidget QToolButton#qt_calendar_yearbutton {
min-width: 70px; padding: 4px 10px; font-size: 13px; font-weight: bold;
color: ${neutral_text}; background-color: ${neutral_bg};
border: 1px solid ${neutral_border}; border-radius: 6px;
}
QCalendarWidget QToolButton#qt_calendar_monthbutton:hover,
QCalendarWidget QToolButton#qt_calendar_yearbutton:hover { background-color: ${neutral_bg_hover}; }
QCalendarWidget QToolButton#qt_calendar_prevmonth,
QCalendarWidget QToolButton#qt_calendar_nextmonth {
width: 28px; height: 28px; background-color: ${neutral_bg};
border: 1px solid ${neutral_border}; border-radius: 6px;
}
QCalendarWidget QToolButton#qt_calendar_prevmonth:hover,
QCalendarWidget QToolButton#qt_calendar_nextmonth:hover { background-color: ${neutral_bg_hover}; }
QCalendarWidget QWidget#qt_calendar_calendarview { font-size: 12px; color: ${text}; background-color: ${panel_bg}; }
QCalendarWidget QAbstractItemView:enabled {
selection-background-color: ${selection_bg}; selection-color: white; font-size: 12px;
}
QCalendarWidget QAbstractItemView:enabled:focus { selection-background-color: ${primary_hover}; }
QStatusBar {
background-color: ${statusbar_bg}; color: ${statusbar_text};
font-size: 13px; font-weight: bold; border-top: 2px solid ${statusbar_accent}; min-height: 34px;
}
QStatusBar::item { border: none; }
QStatusBar QLabel { color: ${statusbar_text}; font-weight: bold; }
QProgressBar {
border: 1px solid ${statusbar_accent}; border-radius: 6px;
background-color: ${progress_bg}; color: ${progress_text};
text-align: center; font-weight: bold; min-height: 20px;
}
QProgressBar::chunk { border-radius: 5px; }
QProgressBar#vksBar::chunk { background-color: ${primary}; }
QProgressBar#webBar::chunk { background-color: ${teal}; }
QLabel#copyStatusLabel { color: ${statusbar_accent}; font-weight: bold; padding-right: 20px; }
QLabel#statusLabel { font-size: 13px; font-weight: bold; padding: 0 8px; color: ${statusbar_text}; }
QLabel#searchCountLabel { color: ${text_muted}; font-size: 12px; font-weight: bold; padding-left: 6px; }
QLabel#modeLabel { color: ${teal}; font-size: 12px; font-weight: bold; }
QToolButton {
background-color: ${toolbtn_bg}; border: 1px solid ${border_strong};
border-radius: 6px; color: ${text};
}
QToolButton:hover { background-color: ${toolbtn_bg_hover}; border: 1px solid ${primary}; }
QToolButton#unlockButton {
background-color: ${teal}; color: white; border: none;
padding: 6px 14px; border-radius: 8px; font-weight: bold;
}
QToolButton#unlockButton:hover { background-color: ${teal_hover}; }
QToolButton#copyFieldButton {
background-color: transparent; border: none; border-radius: 5px; padding: 0;
}
QToolButton#copyFieldButton:hover { background-color: ${neutral_bg_hover}; }
QToolButton#copyFieldButton:disabled { background-color: transparent; }
QDialog { background-color: ${panel_bg}; }
QMessageBox { background-color: ${panel_bg}; }
QFormLayout { font-size: 13px; }
QLineEdit, QSpinBox {
padding: 8px; border: 1px solid ${border_strong}; border-radius: 8px;
background-color: ${field_bg}; color: ${text};
}
QLineEdit:focus, QSpinBox:focus { border: 1px solid ${primary}; }
QLineEdit:disabled, QSpinBox:disabled, QComboBox:disabled,
QDateEdit:disabled, QTimeEdit:disabled, QCheckBox:disabled, QPushButton:disabled {
color: ${text_muted}; background-color: ${panel_alt};
}
QToolTip { background-color: ${panel_bg}; color: ${text}; border: 1px solid ${border_strong}; }
""")
def build_stylesheet(palette):
"""Собирает QSS-строку из палитры."""
return _QSS_TEMPLATE.substitute(palette)
CURRENT_THEME = "light"
def apply_theme(name):
"""Применяет тему: обновляет COLORS, цвет заголовков, QSS приложения и сохраняет выбор."""
global CURRENT_THEME, HEADER_TEXT_COLOR
name = "dark" if name == "dark" else "light"
CURRENT_THEME = name
palette = PALETTE_DARK if name == "dark" else PALETTE_LIGHT
cells = COLORS_DARK if name == "dark" else COLORS_LIGHT
COLORS.clear()
COLORS.update(cells)
HEADER_TEXT_COLOR = palette["header_text"]
app = QtWidgets.QApplication.instance()
if app is not None:
app.setStyleSheet(build_stylesheet(palette))
app.setPalette(_build_qpalette(palette))
try:
settings = QtCore.QSettings("VKSBooking", "vks_app")
settings.setValue("theme", name)
except Exception:
pass
def _build_qpalette(p):
"""Палитра Qt: закрывает то, что не описывается QSS (placeholder, tooltip, выделение)."""
pal = QPalette()
text = QColor(p["text"])
pal.setColor(QPalette.Window, QColor(p["window_bg"]))
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.Base, QColor(p["field_bg"]))
pal.setColor(QPalette.AlternateBase, QColor(p["panel_alt"]))
pal.setColor(QPalette.Text, text)
pal.setColor(QPalette.Button, QColor(p["panel_bg"]))
pal.setColor(QPalette.ButtonText, text)
pal.setColor(QPalette.ToolTipBase, QColor(p["panel_bg"]))
pal.setColor(QPalette.ToolTipText, text)
pal.setColor(QPalette.Highlight, QColor(p["primary"]))
pal.setColor(QPalette.HighlightedText, QColor("#ffffff"))
pal.setColor(QPalette.PlaceholderText, QColor(p["text_muted"]))
pal.setColor(QPalette.Disabled, QPalette.Text, QColor(p["text_muted"]))
pal.setColor(QPalette.Disabled, QPalette.WindowText, QColor(p["text_muted"]))
return pal
def get_saved_theme():
try:
settings = QtCore.QSettings("VKSBooking", "vks_app")
val = settings.value("theme")
if isinstance(val, bytes):
val = val.decode("utf-8", "ignore")
return "dark" if val == "dark" else "light"
except Exception:
return "light"
# Обратная совместимость: STYLESHEET = светлая тема
STYLESHEET = build_stylesheet(PALETTE_LIGHT)
# ---------- Главное окно ----------
class PlusButton(QtWidgets.QPushButton):
"""Кнопка в виде толстого белого плюса с чёрной обводкой (без круглого фона)."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(64, 64)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("Добавить бронирование")
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
center_x = self.width() // 2
center_y = self.height() // 2
arm_length = 22 # Длина "руки" плюса
# Чёрная обводка (толще)
painter.setPen(QPen(QColor("#1a3a33"), 20, Qt.SolidLine, Qt.RoundCap))
painter.drawLine(center_x, center_y - arm_length, center_x, center_y + arm_length)
painter.drawLine(center_x - arm_length, center_y, center_x + arm_length, center_y)
# Белый плюс (тоньше, поверх обводки)
painter.setPen(QPen(Qt.white, 14, Qt.SolidLine, Qt.RoundCap))
painter.drawLine(center_x, center_y - arm_length, center_x, center_y + arm_length)
painter.drawLine(center_x - arm_length, center_y, center_x + arm_length, center_y)
class ColoredHeaderDelegate(QtWidgets.QStyledItemDelegate):
"""Делегат для заголовков таблицы.
Полностью перекрывает отрисовку Qt, рисуя фон из BackgroundRole вручную.
"""
def paint(self, painter, option, index):
painter.save()
# 1. Рисуем фон из BackgroundRole
bg_data = index.data(QtCore.Qt.BackgroundRole)
if isinstance(bg_data, QBrush):
painter.fillRect(option.rect, bg_data)
else:
# Резервный цвет, если BackgroundRole не установлен
painter.fillRect(option.rect, QColor(COLORS["header"]))
# Линия начала недели — рисуется явно, без градиентов
if index.data(QtCore.Qt.UserRole):
painter.setPen(QPen(QColor(COLORS["monday_line"]), 3))
painter.drawLine(option.rect.left() + 1, option.rect.top(),
option.rect.left() + 1, option.rect.bottom())
# 2. Рисуем границы (правая и нижняя, как в CSS)
painter.setPen(QPen(QColor(COLORS["grid_line"]), 1))
# Нижняя граница
painter.drawLine(
option.rect.bottomLeft().x(), option.rect.bottomLeft().y() - 1,
option.rect.bottomRight().x(), option.rect.bottomRight().y() - 1
)
# Правая граница
painter.drawLine(
option.rect.topRight().x() - 1, option.rect.topRight().y(),
option.rect.bottomRight().x() - 1, option.rect.bottomRight().y()
)
# 3. Рисуем текст вручную
text = index.data(QtCore.Qt.DisplayRole)
if text:
# Выравнивание (установлено через setTextAlignment)
alignment = index.data(QtCore.Qt.TextAlignmentRole)
if alignment is None:
alignment = QtCore.Qt.AlignCenter
# Шрифт (установлен через setFont)
font_data = index.data(QtCore.Qt.FontRole)
if font_data:
painter.setFont(font_data)
else:
font = painter.font()
font.setBold(True)
painter.setFont(font)
# Цвет текста
color_data = index.data(QtCore.Qt.ForegroundRole)
if color_data and isinstance(color_data, QBrush):
painter.setPen(color_data.color())
else:
painter.setPen(QColor(HEADER_TEXT_COLOR))
# Отступы (как padding: 8px в CSS)
text_rect = option.rect.adjusted(8, 8, -8, -8)
# Qt.drawText требует int для alignment в некоторых версиях
painter.drawText(text_rect, int(alignment), str(text))
painter.restore()
def sizeHint(self, option, index):
# Используем стандартный размер
return super().sizeHint(option, index)
class BookingCellDelegate(QtWidgets.QStyledItemDelegate):
"""Делегат для ячеек таблицы:
- рисует линию начала недели слева у понедельников (UserRole == True)
- рисует тонкую чёрную обводку у забронированных ячеек
"""
def paint(self, painter, option, index):
super().paint(painter, option, index)
# 1. Линия понедельника
if index.data(QtCore.Qt.UserRole):
painter.save()
painter.setPen(QPen(QColor(COLORS["monday_line"]), 3))
painter.drawLine(option.rect.left() + 1, option.rect.top(),
option.rect.left() + 1, option.rect.bottom())
painter.restore()
# 2. Обводка забронированных ячеек
text = index.data(QtCore.Qt.DisplayRole) or ""
if text and text != "свободно" and text != NO_LICENSE_MARK:
painter.save()
painter.setPen(QPen(QColor("#A1A1A1"), 2))
# Рисуем рамку внутри ячейки, отступая 1px внутрь,
# чтобы не конфликтовать с gridline
r = option.rect.adjusted(1, 1, -1, -1)
painter.drawRect(r)
painter.restore()
# 3. Яркая рамка у ячеек, совпавших с поиском
if index.data(QtCore.Qt.UserRole + 2):
painter.save()
painter.setPen(QPen(QColor(COLORS["search_match_border"]), 3))
painter.drawRect(option.rect.adjusted(2, 2, -2, -2))
painter.restore()
class ColoredVerticalHeaderDelegate(QtWidgets.QStyledItemDelegate):
"""Делегат для вертикального заголовка таблицы (названия комнат).
Рисует фон из BackgroundRole вручную, минуя CSS.
"""
def paint(self, painter, option, index):
painter.save()
# 1. Фон из BackgroundRole
bg_data = index.data(QtCore.Qt.BackgroundRole)
if isinstance(bg_data, QBrush):
painter.fillRect(option.rect, bg_data)
else:
painter.fillRect(option.rect, QColor(COLORS["row_vks"]))
# 2. Границы (нижняя и правая)
painter.setPen(QPen(QColor(COLORS["grid_line"]), 1))
painter.drawLine(
option.rect.bottomLeft().x(), option.rect.bottomLeft().y() - 1,
option.rect.bottomRight().x(), option.rect.bottomRight().y() - 1
)
painter.drawLine(
option.rect.topRight().x() - 1, option.rect.topRight().y(),
option.rect.bottomRight().x() - 1, option.rect.bottomRight().y()
)
# 3. Текст
text = index.data(QtCore.Qt.DisplayRole)
if text:
alignment = index.data(QtCore.Qt.TextAlignmentRole)
if alignment is None:
alignment = QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft
font_data = index.data(QtCore.Qt.FontRole)
if font_data:
painter.setFont(font_data)
else:
font = painter.font()
font.setBold(True)
painter.setFont(font)
color_data = index.data(QtCore.Qt.ForegroundRole)
if color_data and isinstance(color_data, QBrush):
painter.setPen(color_data.color())
else:
painter.setPen(QColor(HEADER_TEXT_COLOR))
# Отступ слева больше (12px) — так смотрится аккуратнее
text_rect = option.rect.adjusted(12, 4, -4, -4)
painter.drawText(text_rect, int(alignment), str(text))
painter.restore()
def sizeHint(self, option, index):
return super().sizeHint(option, index)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Система бронирования ВКС и Вебинаров")
self.resize(1200, 750)
central_widget = QtWidgets.QWidget()
central_widget.setObjectName("centralWidget")
self.setCentralWidget(central_widget)
main_layout = QtWidgets.QVBoxLayout(central_widget)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
toolbar = QtWidgets.QHBoxLayout()
toolbar.setSpacing(10)
self.btnPrev = QtWidgets.QPushButton("← Назад")
self.btnPrev.setObjectName("btnPrev")
self.btnToday = QtWidgets.QPushButton("Сегодня")
self.btnToday.setObjectName("btnToday")
self.btnNext = QtWidgets.QPushButton("Вперёд →")
self.btnNext.setObjectName("btnNext")
self.dateEdit = QtWidgets.QDateEdit()
self.dateEdit.setCalendarPopup(True)
self.dateEdit.setDate(QtCore.QDate.currentDate())
self.viewCombo = QtWidgets.QComboBox()
self.viewCombo.addItems(["Месяц", "Неделя", "20 дней"])
self.viewCombo.setCurrentIndex(2) # 0 = Месяц
self.hideWeekendsCheck = QtWidgets.QCheckBox("Скрывать выходные")
self.hideWeekendsCheck.setChecked(True)
# >>> ПОИСК/ФИЛЬТР ПО БРОНИРОВАНИЯМ <<<
self.search_edit = QtWidgets.QLineEdit()
self.search_edit.setPlaceholderText("Поиск: заказчик, мероприятие, ссылка…")
self.search_edit.setClearButtonEnabled(True)
self.search_edit.setFixedWidth(280)
self.search_edit.setObjectName("searchEdit")
self._search_action = self.search_edit.addAction(
make_icon("search", "#64748b"), QtWidgets.QLineEdit.LeadingPosition
)
self.searchCountLabel = QtWidgets.QLabel("")
self.searchCountLabel.setObjectName("searchCountLabel")
self.search_query = ""
self.search_matches = [] # список (row, col) совпадений
self._search_index = -1
self._search_debounce = QtCore.QTimer(self)
self._search_debounce.setSingleShot(True)
self._search_debounce.setInterval(250)
self._search_debounce.timeout.connect(self._run_search)
self.search_edit.textChanged.connect(lambda _t: self._search_debounce.start())
self.search_edit.returnPressed.connect(self._jump_next_match)
toolbar.addWidget(self.btnPrev)
toolbar.addWidget(self.btnToday)
toolbar.addWidget(self.btnNext)
toolbar.addSpacing(20)
toolbar.addWidget(QtWidgets.QLabel("Дата:"))
toolbar.addWidget(self.dateEdit)
toolbar.addSpacing(20)
toolbar.addWidget(QtWidgets.QLabel("Вид:"))
toolbar.addWidget(self.viewCombo)
toolbar.addSpacing(20)
toolbar.addWidget(self.hideWeekendsCheck)
toolbar.addSpacing(20)
toolbar.addWidget(self.search_edit)
toolbar.addWidget(self.searchCountLabel)
toolbar.addStretch()
self.btnAdd = QtWidgets.QPushButton(" Добавить")
self.btnAdd.setObjectName("btnAdd")
self.btnAdd.setIcon(make_icon("plus", "#ffffff"))
self.btnEditTop = QtWidgets.QPushButton(" Редактировать")
self.btnEditTop.setIcon(make_icon("edit", "#ffffff"))
self.btnDeleteTop = QtWidgets.QPushButton(" Удалить")
self.btnDeleteTop.setObjectName("btnDeleteTop")
self.btnDeleteTop.setIcon(make_icon("trash", "#ffffff"))
self.btnExport = QtWidgets.QPushButton(" Экспорт")
self.btnExport.setObjectName("btnExport")
self.btnExport.setIcon(make_icon("export", "#ffffff"))
self.btnExport.setToolTip("Выгрузить таблицу в Excel (.xlsx) или CSV")
self.btnRefresh = QtWidgets.QPushButton(" Обновить")
self.btnRefresh.setObjectName("btnRefresh")
self.btnRefresh.setIcon(make_icon("refresh", "#ffffff"))
self.btnRefresh.setToolTip("Загрузить изменения других пользователей (F5)")
# Переключатель светлой/тёмной темы (иконка задаётся в apply_icons)
self.btnTheme = QtWidgets.QToolButton()
self.btnTheme.setObjectName("btnTheme")
self.btnTheme.setFixedSize(40, 34)
self.btnTheme.setCursor(Qt.PointingHandCursor)
self.btnTheme.setToolTip("Переключить светлую/тёмную тему")
self.btnTheme.clicked.connect(self.toggle_theme)
toolbar.addWidget(self.btnRefresh)
toolbar.addSpacing(10)
toolbar.addWidget(self.btnAdd)
toolbar.addWidget(self.btnEditTop)
toolbar.addWidget(self.btnDeleteTop)
toolbar.addWidget(self.btnExport)
toolbar.addSpacing(10)
toolbar.addWidget(self.btnTheme)
main_layout.addLayout(toolbar)
self.table = QtWidgets.QTableWidget()
self.table.setShowGrid(True)
self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems)
self.table.verticalHeader().setVisible(True)
self.table.verticalHeader().setDefaultSectionSize(82)
self.table.verticalHeader().setHighlightSections(False) # ← убирает "выделение" первой секции
self.table.verticalHeader().setMinimumWidth(120) # ← минимальная ширина заголовка
# ВАЖНО: храним ссылки на делегаты, иначе Python удалит их сборщиком мусора
# и Qt вернётся к стандартной отрисовке заголовков
self._vheader_delegate = ColoredVerticalHeaderDelegate(self.table.verticalHeader())
self.table.verticalHeader().setItemDelegate(self._vheader_delegate)
header_h = self.table.horizontalHeader()
header_h.setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
header_h.setHighlightSections(False)
header_h.setFixedHeight(67)
self._header_delegate = ColoredHeaderDelegate(header_h) # ← кастомный делегат
header_h.setItemDelegate(self._header_delegate)
main_layout.addWidget(self.table)
self.table.setItemDelegate(BookingCellDelegate(self.table))
self.statusbar = self.statusBar()
self.statusDateLabel = QtWidgets.QLabel()
self.statusDateLabel.setObjectName("statusLabel")
self.statusVksLabel = QtWidgets.QLabel("ВКС:")
self.statusVksLabel.setObjectName("statusLabel")
self.statusVksBar = QtWidgets.QProgressBar()
self.statusVksBar.setObjectName("vksBar")
self.statusVksBar.setMaximum(MAX_VKS_SEATS_PER_DAY)
self.statusVksBar.setTextVisible(True)
self.statusVksBar.setFormat("%v / %m мест")
self.statusVksBar.setFixedWidth(160)
self.statusVksBar.setFixedHeight(22)
self.statusVksRoomsLabel = QtWidgets.QLabel()
self.statusVksRoomsLabel.setObjectName("statusLabel")
self.statusWebLabel = QtWidgets.QLabel("Вебинары:")
self.statusWebLabel.setObjectName("statusLabel")
self.statusWebBar = QtWidgets.QProgressBar()
self.statusWebBar.setObjectName("webBar")
self.statusWebBar.setMaximum(MAX_WEB_SEATS_PER_DAY)
self.statusWebBar.setTextVisible(True)
self.statusWebBar.setFormat("%v / %m мест")
self.statusWebBar.setFixedWidth(160)
self.statusWebBar.setFixedHeight(22)
self.statusWebRoomsLabel = QtWidgets.QLabel()
self.statusWebRoomsLabel.setObjectName("statusLabel")
self.copyStatusLabel = QtWidgets.QLabel("")
self.copyStatusLabel.setObjectName("copyStatusLabel")
sep1 = QtWidgets.QLabel(" | ")
sep2 = QtWidgets.QLabel(" | ")
self.statusbar.addWidget(self.statusDateLabel)
self.statusbar.addWidget(sep1)
self.statusbar.addWidget(self.statusVksLabel)
self.statusbar.addWidget(self.statusVksBar)
self.statusbar.addWidget(self.statusVksRoomsLabel)
self.statusbar.addWidget(sep2)
self.statusbar.addWidget(self.statusWebLabel)
self.statusbar.addWidget(self.statusWebBar)
self.statusbar.addWidget(self.statusWebRoomsLabel)
self.statusbar.addPermanentWidget(self.copyStatusLabel)
self.undo_stack = []
self.redo_stack = []
self.clipboard_booking = None
self.undo_shortcut = QtWidgets.QShortcut(QKeySequence("Ctrl+Z"), self)
self.undo_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
self.undo_shortcut.activated.connect(self.on_undo)
self.redo_shortcut = QtWidgets.QShortcut(QKeySequence("Ctrl+Y"), self)
self.redo_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
self.redo_shortcut.activated.connect(self.on_redo)
self.copy_shortcut = QtWidgets.QShortcut(QKeySequence("Ctrl+C"), self)
self.copy_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
self.copy_shortcut.activated.connect(self.on_copy)
self.paste_shortcut = QtWidgets.QShortcut(QKeySequence("Ctrl+V"), self)
self.paste_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
self.paste_shortcut.activated.connect(self.on_paste)
self.refresh_shortcut = QtWidgets.QShortcut(QKeySequence("F5"), self)
self.refresh_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
self.refresh_shortcut.activated.connect(self.on_refresh)
self.view_mode = "slide20"
# Блокируем чекбокс при запуске, если стартовый режим — 20 дней
if self.view_mode == "slide20":
self.hideWeekendsCheck.blockSignals(True)
self.hideWeekendsCheck.setChecked(True)
self.hideWeekendsCheck.blockSignals(False)
self.hideWeekendsCheck.setEnabled(False)
self.hideWeekendsCheck.setToolTip("В режиме «20 дней» выходные всегда скрыты")
self.center_date = date.today()
self.start_date = None
self.end_date = None
self.rooms = []
self.days = []
self._is_reloading = False
cal = self.dateEdit.calendarWidget()
today_q = QtCore.QDate.currentDate()
fmt = QTextCharFormat(cal.dateTextFormat(today_q))
fnt = fmt.font()
fnt.setBold(True)
fmt.setFont(fnt)
cal.setDateTextFormat(today_q, fmt)
# >>> ПЛЮСИК: толстый белый знак + с чёрным контуром <<<
self.inlineAddButton = PlusButton(central_widget)
self.inlineAddButton.hide()
self.inlineAddButton.clicked.connect(self.on_inline_add_clicked)
self.editButton = QtWidgets.QToolButton(central_widget)
self.editButton.setFixedSize(24, 24)
self.editButton.setToolTip("Редактировать бронирование")
self.editButton.hide()
self.editButton.clicked.connect(self.on_edit_clicked)
self.deleteButton = QtWidgets.QToolButton(central_widget)
self.deleteButton.setFixedSize(24, 24)
self.deleteButton.setToolTip("Удалить бронирование")
self.deleteButton.hide()
self.deleteButton.clicked.connect(self.on_delete_clicked)
self.linkButton = QtWidgets.QToolButton(central_widget)
self.linkButton.setFixedSize(24, 24)
self.linkButton.setToolTip("Копировать ссылку")
self.linkButton.hide()
self.linkButton.clicked.connect(self.on_link_clicked)
self.mailButton = QtWidgets.QToolButton(central_widget)
self.mailButton.setFixedSize(24, 24)
self.mailButton.setToolTip("Скопировать письмо заказчику в буфер обмена")
self.mailButton.hide()
self.mailButton.clicked.connect(self.on_mail_clicked)
self.dateEdit.dateChanged.connect(self.on_date_changed)
self.viewCombo.currentIndexChanged.connect(self.on_view_changed)
self.hideWeekendsCheck.toggled.connect(self.on_hide_weekends_toggled)
self.btnPrev.clicked.connect(self.on_prev)
self.btnNext.clicked.connect(self.on_next)
self.btnToday.clicked.connect(self.on_today)
self.btnAdd.clicked.connect(self.on_add_booking)
self.btnEditTop.clicked.connect(self.on_edit_clicked)
self.btnDeleteTop.clicked.connect(self.on_delete_clicked)
self.btnExport.clicked.connect(self.on_export_excel)
self.table.cellDoubleClicked.connect(self.on_cell_double_clicked)
self.table.cellClicked.connect(self.on_cell_clicked)
self.table.selectionModel().selectionChanged.connect(self.on_selection_changed)
self.table.verticalScrollBar().valueChanged.connect(self._on_scroll)
self.table.horizontalScrollBar().valueChanged.connect(self._on_scroll)
self.table.installEventFilter(self)
self.apply_icons()
self.reload_data()
self.btnRefresh.clicked.connect(self.on_refresh)
self.start_auto_refresh(1200) # Автообновление каждые 20 минут
def apply_icons(self):
"""Задаёт SVG-иконки, зависящие от текущей темы."""
dark = (CURRENT_THEME == "dark")
tool_color = "#e6edf5" if dark else "#334e68"
self.editButton.setIcon(make_icon("edit", tool_color))
self.deleteButton.setIcon(make_icon("trash", tool_color))
self.linkButton.setIcon(make_icon("link", tool_color))
self.mailButton.setIcon(make_icon("mail", tool_color))
if dark:
self.btnTheme.setIcon(make_icon("sun", "#f5b942"))
else:
self.btnTheme.setIcon(make_icon("moon", "#5a6b7d"))
if hasattr(self, "_search_action"):
self._search_action.setIcon(make_icon("search", "#93a4b8" if dark else "#64748b"))
def toggle_theme(self):
"""Переключает светлую/тёмную тему и перерисовывает интерфейс."""
apply_theme("dark" if CURRENT_THEME != "dark" else "light")
self.apply_icons()
self.reload_data()
def _on_scroll(self):
if not self._is_reloading:
QtCore.QTimer.singleShot(0, self.reposition_buttons)
def eventFilter(self, source, event):
if source is self.table:
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Delete:
self.on_delete_clicked()
return True
elif event.type() == QtCore.QEvent.Wheel:
# В режиме "20 дней" колёсико сдвигает окно на 1 день
if self.view_mode == "slide20":
if event.angleDelta().y() > 0:
self.center_date = self._shift_workdays(self.center_date, -1)
else:
self.center_date = self._shift_workdays(self.center_date, 1)
self.dateEdit.blockSignals(True)
self.dateEdit.setDate(QtCore.QDate(
self.center_date.year,
self.center_date.month,
self.center_date.day
))
self.dateEdit.blockSignals(False)
self.reload_data()
return True # съели событие, таблица не скроллится
return super().eventFilter(source, event)
def resizeEvent(self, event):
super().resizeEvent(event)
if not self._is_reloading:
QtCore.QTimer.singleShot(0, self.reposition_buttons)
def _hide_all_buttons(self):
self.inlineAddButton.hide()
self.editButton.hide()
self.deleteButton.hide()
self.linkButton.hide()
self.mailButton.hide()
def reposition_buttons(self):
if self._is_reloading:
return
try:
if self.table.rowCount() == 0 or self.table.columnCount() == 0:
self._hide_all_buttons()
return
if not self.rooms or not self.days:
self._hide_all_buttons()
return
span = self._get_selection_span()
if span is None:
self._hide_all_buttons()
return
row, _start_col, last_col = span
if (row < 0 or row >= self.table.rowCount() or
last_col < 0 or last_col >= self.table.columnCount()):
self._hide_all_buttons()
return
if row >= len(self.rooms) or last_col >= len(self.days):
self._hide_all_buttons()
return
item = self.table.item(row, last_col)
text = item.text() if item is not None else ""
index = self.table.model().index(row, last_col)
rect = self.table.visualRect(index)
viewport_rect = self.table.viewport().rect()
if not rect.intersects(viewport_rect):
self._hide_all_buttons()
return
viewport_pos = self.table.viewport().mapToParent(rect.topLeft())
table_pos = self.table.mapToParent(viewport_pos)
btn_margin = 4
if text == NO_LICENSE_MARK:
# Красная ячейка — никаких кнопок
self._hide_all_buttons()
elif text == "" or text == "свободно":
# Позиционирование по центру ячейки
x = table_pos.x() + (rect.width() - self.inlineAddButton.width()) // 2
y = table_pos.y() + (rect.height() - self.inlineAddButton.height()) // 2
self.inlineAddButton.move(x, y)
self.inlineAddButton.show()
self.inlineAddButton.raise_()
self.editButton.hide()
self.deleteButton.hide()
self.linkButton.hide()
self.mailButton.hide()
else:
self.inlineAddButton.hide()
btn_size = self.editButton.width()
base_x = table_pos.x() + rect.width() - btn_margin - btn_size
y = table_pos.y() + btn_margin
self.deleteButton.move(base_x, y)
self.editButton.move(base_x - (btn_size + 2), y)
room = self.rooms[row]
day = self.days[last_col]
rng = get_booking_range(room["id"], day)
has_link = False
if rng is not None:
link = rng[5]
has_link = bool(link)
if has_link:
self.linkButton.move(base_x - 2 * (btn_size + 2), y)
self.linkButton.show()
self.linkButton.raise_()
else:
self.linkButton.hide()
self.mailButton.move(base_x - 3 * (btn_size + 2), y)
self.mailButton.show()
self.mailButton.raise_()
self.deleteButton.show()
self.deleteButton.raise_()
self.editButton.show()
self.editButton.raise_()
except Exception as e:
print(f"reposition_buttons error: {e}")
self._hide_all_buttons()
def calc_range(self):
if self.view_mode == "week":
weekday = self.center_date.weekday()
self.start_date = self.center_date - timedelta(days=weekday)
self.end_date = self.start_date + timedelta(days=6)
elif self.view_mode == "slide20":
# Окно из ровно 20 БУДНИХ дней, начиная с center_date
d = self.center_date
while d.weekday() >= 5: # если центр на выходном — ближайший будний вперёд
d += timedelta(days=1)
self.start_date = d
cur = d
count = 1
while count < 20:
cur += timedelta(days=1)
if cur.weekday() < 5:
count += 1
self.end_date = cur
else:
self.start_date = self.center_date.replace(day=1)
if self.start_date.month == 12:
next_month = self.start_date.replace(year=self.start_date.year + 1, month=1, day=1)
else:
next_month = self.start_date.replace(month=self.start_date.month + 1, day=1)
self.end_date = next_month - timedelta(days=1)
def _shift_workdays(self, base, n):
"""Сдвигает дату на n будних дней (n может быть отрицательным).
Выходные просто перепрыгиваются."""
d = base
step = 1 if n > 0 else -1
for _ in range(abs(n)):
d += timedelta(days=step)
while d.weekday() >= 5:
d += timedelta(days=step)
return d
def _make_add_actions(self, room, data):
def undo():
delete_booking_range(room["id"], data["date_from"], data["date_to"],
data["client_name"], data["description"],
data["seats_used"], data["link"])
def redo():
add_booking_range(room, data["date_from"], data["date_to"],
data["client_name"], data["description"],
data["seats_used"], data["link"],
data["time_open"], data["time_close"], data["doklad_link"],
data["moder_link"], data["event_id"], data["contact_name"],
data["training"], data["event_name"],
has_foreign=data.get("has_foreign", 0),
foreign_guest_link=data.get("foreign_guest_link"),
foreign_doklad_link=data.get("foreign_doklad_link"),
foreign_moder_link=data.get("foreign_moder_link"),
foreign_event_id=data.get("foreign_event_id"),
foreign_country=data.get("foreign_country"))
return undo, redo
def _make_delete_actions(self, room, s_date, e_date, client, desc, seats, link):
def undo():
add_booking_range(room, s_date, e_date, client, desc, seats, link)
def redo():
delete_booking_range(room["id"], s_date, e_date, client, desc, seats, link)
return undo, redo
def _make_edit_actions(self, room, old_data, new_data):
def undo():
delete_booking_range(new_data["room"]["id"], new_data["date_from"], new_data["date_to"],
new_data["client_name"], new_data["description"],
new_data["seats_used"], new_data["link"])
add_booking_range(room, old_data["date_from"], old_data["date_to"],
old_data["client_name"], old_data["description"],
old_data["seats_used"], old_data["link"],
old_data["time_open"], old_data["time_close"], old_data["doklad_link"],
old_data["moder_link"], old_data["event_id"], old_data["contact_name"],
old_data["training"], old_data["event_name"],
has_foreign=old_data.get("has_foreign", 0),
foreign_guest_link=old_data.get("foreign_guest_link"),
foreign_doklad_link=old_data.get("foreign_doklad_link"),
foreign_moder_link=old_data.get("foreign_moder_link"),
foreign_event_id=old_data.get("foreign_event_id"),
foreign_country=old_data.get("foreign_country"))
def redo():
delete_booking_range(room["id"], old_data["date_from"], old_data["date_to"],
old_data["client_name"], old_data["description"],
old_data["seats_used"], old_data["link"])
add_booking_range(new_data["room"], new_data["date_from"], new_data["date_to"],
new_data["client_name"], new_data["description"],
new_data["seats_used"], new_data["link"],
new_data["time_open"], new_data["time_close"], new_data["doklad_link"],
new_data["moder_link"], new_data["event_id"], new_data["contact_name"],
new_data["training"], new_data["event_name"],
has_foreign=new_data.get("has_foreign", 0),
foreign_guest_link=new_data.get("foreign_guest_link"),
foreign_doklad_link=new_data.get("foreign_doklad_link"),
foreign_moder_link=new_data.get("foreign_moder_link"),
foreign_event_id=new_data.get("foreign_event_id"),
foreign_country=new_data.get("foreign_country"))
return undo, redo
def _run_search(self):
"""Сохраняет запрос поиска и перерисовывает таблицу."""
self.search_query = self.search_edit.text()
self.reload_data()
if self.search_matches:
self._jump_next_match()
def _jump_next_match(self):
"""Выбирает следующее совпадение поиска (по циклу)."""
if not getattr(self, "search_matches", None):
return
self._search_index = (self._search_index + 1) % len(self.search_matches)
row, col = self.search_matches[self._search_index]
item = self.table.item(row, col)
if item is None:
return
self.table.blockSignals(True)
self.table.clearSelection()
self.table.setCurrentCell(row, col)
self.table.scrollToItem(item, QtWidgets.QAbstractItemView.PositionAtCenter)
self.table.selectionModel().select(
self.table.model().index(row, col),
QtCore.QItemSelectionModel.Select
)
self.table.blockSignals(False)
QtCore.QTimer.singleShot(0, self.reposition_buttons)
def reload_data(self):
self._is_reloading = True
try:
self.rooms = fetch_rooms()
self.calc_range()
hide_weekends = self.hideWeekendsCheck.isChecked()
self.days = []
d = self.start_date
while d <= self.end_date:
if hide_weekends and d.weekday() >= 5:
d += timedelta(days=1)
continue
self.days.append(d)
d += timedelta(days=1)
bookings = fetch_bookings(self.start_date, self.end_date)
bookings_map = {}
for b in bookings:
key = (b["room_id"], b["date"])
bookings_map.setdefault(key, []).append(b)
# Занятые лицензии по дням и типам — для подсветки "лицензий не осталось"
day_seats = {}
for b in bookings:
day_seats.setdefault(b["date"], {"vks": 0, "webinar": 0})
day_seats[b["date"]][b["room_type"]] += b["seats_used"] or 0
self._hide_all_buttons()
self.table.blockSignals(True)
self.table.setRowCount(0)
self.table.setColumnCount(0)
self.table.setRowCount(len(self.rooms))
self.table.setColumnCount(len(self.days))
qd = self.dateEdit.date()
highlight_day = date(qd.year(), qd.month(), qd.day())
today = date.today()
for col, day in enumerate(self.days):
wd = RU_WEEKDAYS[day.weekday()]
date_str = day.strftime('%d.%m')
# Дата сверху, день недели снизу
text = f"{date_str}\n{wd}"
item = QtWidgets.QTableWidgetItem(text)
item.setTextAlignment(QtCore.Qt.AlignCenter)
item.setToolTip(day.strftime("%d.%m.%Y"))
# >>> ПАРАМЕТР ДЛЯ НАСТРОЙКИ РАЗМЕРА ШРИФТА ЗАГОЛОВКОВ <<<
# Меняйте число 9 на нужное значение: 8 (меньше), 10, 11 (больше)
if self.view_mode in ("month", "slide20"):
font = QFont("Segoe UI", 9)
item.setFont(font)
else:
font = QFont("Segoe UI", 10)
font.setBold(False)
item.setFont(font)
# >>> ЦВЕТ ТЕКУЩЕЙ ДАТЫ <<<
bg_color = QColor(COLORS["header"])
if day.weekday() >= 5:
bg_color = QColor(COLORS["header_weekend"])
if day == highlight_day:
bg_color = QColor(COLORS["header_selected"])
if day == today:
bg_color = QColor(COLORS["header_today"])
font = item.font()
font.setBold(True)
item.setFont(font)
item.setBackground(bg_color)
item.setForeground(QColor(HEADER_TEXT_COLOR))
if day.weekday() == 0:
item.setData(QtCore.Qt.UserRole, True)
self.table.setHorizontalHeaderItem(col, item)
for row, room in enumerate(self.rooms):
item = QtWidgets.QTableWidgetItem(room["name"])
item.setTextAlignment(QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft)
item.setFont(QFont("Segoe UI", 10, QFont.Bold))
# Цвет фона зависит от типа комнаты
if room["type"] == "vks":
item.setBackground(QColor(COLORS["row_vks"]))
else:
item.setBackground(QColor(COLORS["row_webinar"]))
item.setForeground(QColor(HEADER_TEXT_COLOR))
self.table.setVerticalHeaderItem(row, item)
# >>> ПОИСК: готовим список совпадений и затемнение <<<
self.search_matches = []
query = self.search_query.strip().lower()
for row, room in enumerate(self.rooms):
for col, day in enumerate(self.days):
key = (room["id"], day.isoformat())
cell_bookings = bookings_map.get(key)
if cell_bookings:
parts = []
parts_full = []
for b in cell_bookings:
client = b["client_name"] or ""
training = b["training"] if "training" in b.keys() else None
event_name = b["event_name"] if "event_name" in b.keys() else None
desc = (training or event_name or b["description"] or "")
seats = b["seats_used"] or 0
line3 = f"{seats} {plural_uchastnik(seats)}" if seats > 0 else ""
# Обрезаем длинный текст, чтобы количество участников всегда было видно
MAX_DESC = 40
desc_display = desc if len(desc) <= MAX_DESC else desc[:MAX_DESC] + "…"
txt_parts = [client, desc_display, line3]
txt = "\n".join(p for p in txt_parts if p)
parts.append(txt)
txt_parts_full = [client, desc, line3]
txt_full = "\n".join(p for p in txt_parts_full if p)
parts_full.append(txt_full)
cell_text = "\n---\n".join(parts)
cell_text_full = "\n---\n".join(parts_full)
item = QtWidgets.QTableWidgetItem(cell_text)
item.setToolTip(cell_text_full)
item.setTextAlignment(QtCore.Qt.AlignCenter) # центрирование текста
# Цвет: есть ссылка у всех бронирований ячейки — цвет по типу комнаты.
# Нет ссылки хотя бы у одного — серый (ссылки ещё не отправлены).
all_have_link = all(bool(b["link"]) for b in cell_bookings)
if all_have_link:
if room["type"] == "vks":
bg_color = QColor(COLORS["vks_booked"])
else:
bg_color = QColor(COLORS["webinar_booked"])
else:
bg_color = QColor(COLORS["booked_no_link"])
# >>> ПОИСК: совпадение подсвечиваем, остальное затемняем <<<
if query:
fields = []
for b in cell_bookings:
for fname in ("client_name", "training", "event_name",
"description", "contact_name", "link",
"doklad_link", "moder_link", "event_id",
"foreign_country"):
if fname in b.keys() and b[fname]:
fields.append(str(b[fname]))
search_text = " ".join(fields).lower()
if query in search_text:
item.setData(QtCore.Qt.UserRole + 2, True) # маркер совпадения
self.search_matches.append((row, col))
else:
bg_color = QColor(COLORS["search_dim"])
item.setBackground(bg_color)
# Пометка в подсказке, чтобы не гадать по цвету
if not all_have_link:
item.setToolTip(cell_text + "\n⚠Предварительное бронирование⚠")
if day.weekday() == 0:
item.setData(QtCore.Qt.UserRole, True)
self.table.setItem(row, col, item)
else:
seats_day = day_seats.get(day.isoformat(), {"vks": 0, "webinar": 0})
limit = MAX_VKS_SEATS_PER_DAY if room["type"] == "vks" else MAX_WEB_SEATS_PER_DAY
no_licenses = seats_day[room["type"]] >= limit
if no_licenses:
# Комната свободна, но лицензий на день не осталось
item = QtWidgets.QTableWidgetItem(NO_LICENSE_MARK)
item.setForeground(QColor(COLORS["free_no_licenses_text"]))
item.setTextAlignment(QtCore.Qt.AlignCenter)
font = item.font()
font.setBold(True)
font.setPointSize(font.pointSize() + 2)
item.setFont(font)
item.setToolTip("Нет свободных лицензий на этот день")
bg_color = QColor(COLORS["free_no_licenses"])
else:
item = QtWidgets.QTableWidgetItem("свободно")
item.setForeground(QtCore.Qt.gray)
item.setTextAlignment(QtCore.Qt.AlignCenter)
font = item.font()
font.setPointSize(max(font.pointSize() - 1, 8))
item.setFont(font)
if day.weekday() >= 5:
bg_color = QColor(COLORS["free_weekend"])
else:
if room["type"] == "vks":
bg_color = QColor(COLORS["free_vks"])
else:
bg_color = QColor(COLORS["free_webinar"])
item.setBackground(bg_color)
if day.weekday() == 0:
item.setData(QtCore.Qt.UserRole, True)
self.table.setItem(row, col, item)
self.table.resizeRowsToContents()
self.apply_spans()
uniform_height = 82
for row in range(self.table.rowCount()):
self.table.setRowHeight(row, uniform_height)
# >>> ПОИСК: схлопываем соседние колонки одной строки в одно совпадение <<<
if query:
matched_set = set(self.search_matches)
self.search_matches = sorted(
pos for pos in matched_set if (pos[0], pos[1] - 1) not in matched_set
)
n = len(self.search_matches)
self.searchCountLabel.setText(
f"Найдено: {n}" if n else "Не найдено"
)
else:
self.search_matches = []
self.searchCountLabel.setText("")
self._search_index = -1
self.update_status_for_day(self.center_date)
# Сбрасываем текущую ячейку — убираем дефолтную чёрную подсветку первой ячейки
self.table.setCurrentItem(None)
finally:
self.table.blockSignals(False)
self._is_reloading = False
QtCore.QTimer.singleShot(0, self.reposition_buttons)
def apply_spans(self):
rows = self.table.rowCount()
cols = self.table.columnCount()
for r in range(rows):
for c in range(cols):
self.table.setSpan(r, c, 1, 1)
for r in range(rows):
run_start = None
run_text = None
for c in range(cols + 1):
if c < cols:
item = self.table.item(r, c)
text = item.text() if item is not None else ""
is_booking = (text != "" and text != "свободно" and text != NO_LICENSE_MARK)
else:
item = None
text = ""
is_booking = False
if run_start is None:
if is_booking:
run_start = c
run_text = text
else:
if (not is_booking) or (text != run_text):
length = c - run_start
if length > 1:
self.table.setSpan(r, run_start, 1, length)
run_start = None
run_text = None
if is_booking:
run_start = c
run_text = text
def update_status_for_day(self, day):
stats = get_day_stats(day)
vks_seats = stats["vks"]["seats_used"]
vks_rooms = stats["vks"]["rooms_used"]
web_seats = stats["webinar"]["seats_used"]
web_rooms = stats["webinar"]["rooms_used"]
weekday_name = ["Понедельник", "Вторник", "Среда", "Четверг",
"Пятница", "Суббота", "Воскресенье"][day.weekday()]
self.statusDateLabel.setText(f"{weekday_name}, {day.strftime('%d.%m.%Y')}")
self.statusVksBar.setValue(vks_seats)
self.statusVksBar.setFormat(f"{vks_seats} / {MAX_VKS_SEATS_PER_DAY} мест")
self.statusVksRoomsLabel.setText(f"({vks_rooms}/{MAX_VKS_ROOMS_PER_DAY} комн.)")
self.statusWebBar.setValue(web_seats)
self.statusWebBar.setFormat(f"{web_seats} / {MAX_WEB_SEATS_PER_DAY} мест")
self.statusWebRoomsLabel.setText(f"({web_rooms}/{MAX_WEB_ROOMS_PER_DAY} комн.)")
if vks_seats >= MAX_VKS_SEATS_PER_DAY:
self.statusVksBar.setStyleSheet("QProgressBar::chunk { background-color: #e74c3c; }")
elif vks_seats >= MAX_VKS_SEATS_PER_DAY * 0.7:
self.statusVksBar.setStyleSheet("QProgressBar::chunk { background-color: #f39c12; }")
else:
self.statusVksBar.setStyleSheet("QProgressBar::chunk { background-color: #3498db; }")
if web_seats >= MAX_WEB_SEATS_PER_DAY:
self.statusWebBar.setStyleSheet("QProgressBar::chunk { background-color: #e74c3c; }")
elif web_seats >= MAX_WEB_SEATS_PER_DAY * 0.7:
self.statusWebBar.setStyleSheet("QProgressBar::chunk { background-color: #f39c12; }")
else:
self.statusWebBar.setStyleSheet("QProgressBar::chunk { background-color: #2ecc71; }")
def calculate_max_seats_for_range(self, room, start_date, end_date, existing_seats=0):
if room["type"] == "vks":
limit = MAX_VKS_SEATS_PER_DAY
stats_key = "vks"
else:
limit = MAX_WEB_SEATS_PER_DAY
stats_key = "webinar"
max_allowed = limit
current = start_date
while current <= end_date:
stats = get_day_stats(current)
used = stats[stats_key]["seats_used"]
if existing_seats:
used = max(0, used - existing_seats)
remaining = limit - used
if remaining < max_allowed:
max_allowed = remaining
current += timedelta(days=1)
if max_allowed < 0:
max_allowed = 0
return max_allowed
def on_date_changed(self, qdate):
self.center_date = date(qdate.year(), qdate.month(), qdate.day())
self.reload_data()
def on_view_changed(self, index):
text = self.viewCombo.currentText().lower()
if "недел" in text:
self.view_mode = "week"
elif "20" in text:
self.view_mode = "slide20"
else:
self.view_mode = "month"
if self.view_mode == "slide20":
# Если попали в режим в выходной — встаём на ближайший будний
while self.center_date.weekday() >= 5:
self.center_date += timedelta(days=1)
# В режиме "20 дней" выходные всегда скрыты и чекбокс заблокирован
self.hideWeekendsCheck.blockSignals(True) # не дёргаем лишний reload
self.hideWeekendsCheck.setChecked(True)
self.hideWeekendsCheck.blockSignals(False)
self.hideWeekendsCheck.setEnabled(False)
self.hideWeekendsCheck.setToolTip("В режиме «20 дней» выходные всегда скрыты")
else:
# В остальных режимах чекбокс снова доступен
self.hideWeekendsCheck.setEnabled(True)
self.hideWeekendsCheck.setToolTip("")
self.reload_data()
def on_hide_weekends_toggled(self, checked):
self.reload_data()
def on_prev(self):
if self.view_mode == "week":
self.center_date -= timedelta(days=7)
elif self.view_mode == "slide20":
self.center_date = self._shift_workdays(self.center_date, -5)
else:
y, m = self.center_date.year, self.center_date.month
if m == 1:
y -= 1
m = 12
else:
m -= 1
self.center_date = self.center_date.replace(year=y, month=m, day=1)
self.dateEdit.setDate(QtCore.QDate(self.center_date.year, self.center_date.month, self.center_date.day))
self.reload_data()
def on_next(self):
if self.view_mode == "week":
self.center_date += timedelta(days=7)
elif self.view_mode == "slide20":
self.center_date = self._shift_workdays(self.center_date, 5)
else:
y, m = self.center_date.year, self.center_date.month
if m == 12:
y += 1
m = 1
else:
m += 1
self.center_date = self.center_date.replace(year=y, month=m, day=1)
self.dateEdit.setDate(QtCore.QDate(self.center_date.year, self.center_date.month, self.center_date.day))
self.reload_data()
def on_today(self):
self.center_date = date.today()
self.dateEdit.setDate(QtCore.QDate.currentDate())
self.reload_data()
def on_refresh(self):
"""Обновляет данные из БД — синхронизирует изменения других пользователей."""
self.reload_data()
# Показываем краткое уведомление в статусбаре
self.copyStatusLabel.setText("✓ Данные обновлены")
QtCore.QTimer.singleShot(2000, lambda: self.copyStatusLabel.setText(""))
def start_auto_refresh(self, interval_seconds=60):
"""Запускает автообновление каждые N секунд.
Полезно для многопользовательской работы — все видят изменения друг друга."""
self._auto_refresh_timer = QtCore.QTimer(self)
self._auto_refresh_timer.timeout.connect(self._silent_refresh)
self._auto_refresh_timer.start(interval_seconds * 1000)
def _silent_refresh(self):
"""Тихое обновление без уведомления (не отвлекает пользователя)."""
self.reload_data()
def _get_selection_span(self):
indexes = self.table.selectedIndexes()
if not indexes:
return None
rows = {idx.row() for idx in indexes}
if len(rows) != 1:
return None
row = rows.pop()
cols = [idx.column() for idx in indexes]
start_col = min(cols)
end_col = max(cols)
return row, start_col, end_col
def get_selection_range(self):
indexes = self.table.selectedIndexes()
if not indexes:
return None, "no_selection"
rows = {idx.row() for idx in indexes}
if len(rows) != 1:
return None, "multi_row"
row = rows.pop()
cols = [idx.column() for idx in indexes]
start_col = min(cols)
end_col = max(cols)
if start_col < 0 or end_col >= len(self.days):
return None, "invalid"
if row < 0 or row >= len(self.rooms):
return None, "invalid"
room = self.rooms[row]
start_date = self.days[start_col]
end_date = self.days[end_col]
return (room, start_date, end_date), "ok"
def push_undo(self, undo_action, redo_action=None):
self.undo_stack.append((undo_action, redo_action))
self.redo_stack.clear()
def on_undo(self):
if not self.undo_stack:
return
undo_action, redo_action = self.undo_stack.pop()
undo_action()
if redo_action:
self.redo_stack.append((undo_action, redo_action))
self.reload_data()
def on_redo(self):
if not self.redo_stack:
return
undo_action, redo_action = self.redo_stack.pop()
redo_action()
self.undo_stack.append((undo_action, redo_action))
self.reload_data()
def on_copy(self):
span = self._get_selection_span()
if span is None:
return
row, _start_col, last_col = span
if row >= len(self.rooms) or last_col >= len(self.days):
return
room = self.rooms[row]
day = self.days[last_col]
rng = get_booking_range(room["id"], day)
if rng is None:
return
self.clipboard_booking = {
"room_id": room["id"],
"room_type": room["type"],
"client_name": rng[2],
"description": rng[3],
"seats_used": rng[4],
"link": rng[5],
"time_open": rng[6],
"time_close": rng[7],
"doklad_link": rng[9],
"moder_link": rng[10],
"event_id": rng[11],
"contact_name": rng[12],
"training": rng[13],
"event_name": rng[14],
"has_foreign": rng[15],
"foreign_guest_link": rng[16],
"foreign_doklad_link": rng[17],
"foreign_moder_link": rng[18],
"foreign_event_id": rng[19],
"foreign_country": rng[20],
"start_date": rng[0],
"end_date": rng[1],
}
self.copyStatusLabel.setText("✓ Мероприятие скопировано")
QtCore.QTimer.singleShot(3000, lambda: self.copyStatusLabel.setText(""))
def on_paste(self):
if self.clipboard_booking is None:
return
span = self._get_selection_span()
if span is None:
return # Нет выделения — ничего не делаем
row, start_col, _end_col = span
if row >= len(self.rooms) or start_col >= len(self.days):
return
target_room = self.rooms[row]
target_start_date = self.days[start_col]
# ПУНКТ 5: ЗАПРЕТ ВСТАВКИ МЕЖДУ ВКС И ВЕБИНАРОМ
source_type = self.clipboard_booking.get("room_type")
if source_type is None:
# Пытаемся получить тип из БД, если не сохранился
source_type = get_room_type(self.clipboard_booking["room_id"])
if source_type and source_type != target_room["type"]:
source_name = "ВКС" if source_type == "vks" else "Вебинар"
target_name = "ВКС" if target_room["type"] == "vks" else "Вебинар"
QtWidgets.QMessageBox.warning(
self, "Невозможно вставить",
f"Нельзя вставить мероприятие типа «{source_name}» в комнату типа «{target_name}»."
)
return
original_start = self.clipboard_booking["start_date"]
original_end = self.clipboard_booking["end_date"]
duration_days = (original_end - original_start).days
target_end_date = target_start_date + timedelta(days=duration_days)
ok, msg = can_add_booking_range(
target_room, target_start_date, target_end_date,
self.clipboard_booking["seats_used"]
)
if not ok:
QtWidgets.QMessageBox.warning(self, "Ошибка", msg)
return
data = {
"room": target_room,
"date_from": target_start_date,
"date_to": target_end_date,
"client_name": self.clipboard_booking["client_name"],
"description": self.clipboard_booking["description"],
"seats_used": self.clipboard_booking["seats_used"],
"link": self.clipboard_booking["link"],
"time_open": self.clipboard_booking.get("time_open"),
"time_close": self.clipboard_booking.get("time_close"),
"doklad_link": self.clipboard_booking.get("doklad_link"),
"moder_link": self.clipboard_booking.get("moder_link"),
"event_id": self.clipboard_booking.get("event_id"),
"contact_name": self.clipboard_booking.get("contact_name"),
"training": self.clipboard_booking.get("training"),
"event_name": self.clipboard_booking.get("event_name"),
"has_foreign": self.clipboard_booking.get("has_foreign", 0),
"foreign_guest_link": self.clipboard_booking.get("foreign_guest_link"),
"foreign_doklad_link": self.clipboard_booking.get("foreign_doklad_link"),
"foreign_moder_link": self.clipboard_booking.get("foreign_moder_link"),
"foreign_event_id": self.clipboard_booking.get("foreign_event_id"),
"foreign_country": self.clipboard_booking.get("foreign_country")
}
ok, msg = add_booking_range(
data["room"],
data["date_from"],
data["date_to"],
data["client_name"],
data["description"],
data["seats_used"],
link=data["link"],
time_open=data["time_open"],
time_close=data["time_close"],
doklad_link=data["doklad_link"],
moder_link=data["moder_link"],
event_id=data["event_id"],
contact_name=data["contact_name"],
training=data["training"],
event_name=data["event_name"],
has_foreign=data["has_foreign"],
foreign_guest_link=data["foreign_guest_link"],
foreign_doklad_link=data["foreign_doklad_link"],
foreign_moder_link=data["foreign_moder_link"],
foreign_event_id=data["foreign_event_id"],
foreign_country=data["foreign_country"],
)
if ok:
undo_fn, redo_fn = self._make_add_actions(target_room, data)
self.push_undo(undo_fn, redo_fn)
self.reload_data()
def on_add_booking(self):
sel, status = self.get_selection_range()
if status == "multi_row":
QtWidgets.QMessageBox.warning(
self, "Неверное выделение",
"Выделите ячейки только в одной строке."
)
return
if status == "no_selection":
# НЕТ ВЫДЕЛЕНИЯ — ставим начало периода в зависимости от режима
if not self.rooms:
return
room = self.rooms[0]
if self.view_mode == "week":
weekday = self.center_date.weekday()
start_date = self.center_date - timedelta(days=weekday)
elif self.view_mode == "slide20":
start_date = self.center_date
else:
start_date = self.center_date.replace(day=1)
end_date = start_date
elif status == "ok":
room, start_date, end_date = sel
span = self._get_selection_span()
if span is not None:
row, start_col, end_col = span
for col in range(start_col, end_col + 1):
item = self.table.item(row, col)
text = item.text() if item is not None else ""
if text == NO_LICENSE_MARK:
QtWidgets.QMessageBox.warning(
self, "Нет лицензий",
f"На {self.days[col].strftime('%d.%m.%Y')} нет свободных лицензий.\n"
"Выберите другой день или другую комнату."
)
return
if text not in ("", "свободно"):
QtWidgets.QMessageBox.warning(
self, "Нельзя добавить",
"Для добавления бронирования выделите свободные ячейки одной строки"
)
return
else:
return
max_seats = self.calculate_max_seats_for_range(room, start_date, end_date, existing_seats=0)
dlg = BookingDialog(room, start_date, end_date, max_seats_available=max_seats, parent=self)
if dlg.exec_() == QtWidgets.QDialog.Accepted:
data = dlg.get_data()
ok, msg = add_booking_range(
room=data["room"],
start_date=data["date_from"],
end_date=data["date_to"],
client_name=data["client_name"],
description=data["description"],
seats_used=data["seats_used"],
link=data["link"],
time_open=data["time_open"],
time_close=data["time_close"],
doklad_link=data["doklad_link"],
moder_link=data["moder_link"],
event_id=data["event_id"],
contact_name=data["contact_name"],
training=data["training"],
event_name=data["event_name"],
has_foreign=data["has_foreign"],
foreign_guest_link=data["foreign_guest_link"],
foreign_doklad_link=data["foreign_doklad_link"],
foreign_moder_link=data["foreign_moder_link"],
foreign_event_id=data["foreign_event_id"],
foreign_country=data["foreign_country"],
)
if not ok:
QtWidgets.QMessageBox.critical(self, "Ошибка", msg)
return
undo_fn, redo_fn = self._make_add_actions(data["room"], data)
self.push_undo(undo_fn, redo_fn)
self.reload_data()
def on_inline_add_clicked(self):
self.on_add_booking()
def _get_selected_cell_room_day(self):
span = self._get_selection_span()
if span is None:
return None
row, _start_col, end_col = span
if row < 0 or row >= len(self.rooms):
return None
if end_col < 0 or end_col >= len(self.days):
return None
return self.rooms[row], self.days[end_col]
def on_edit_clicked(self):
res = self._get_selected_cell_room_day()
if res is None:
return
room, day = res
self._open_booking_editor(room, day, view_only=False)
def _open_booking_editor(self, room, day, view_only=False):
"""Открывает диалог существующего бронирования.
view_only=True — режим просмотра (поля заблокированы, есть кнопка разблокировки)."""
rng = get_booking_range(room["id"], day)
if rng is None:
return
start_date, end_date, client, desc, seats, link, \
time_open, time_close, guest_link, doklad_link, moder_link, \
event_id, contact_name, training, event_name, \
has_foreign, foreign_guest_link, foreign_doklad_link, \
foreign_moder_link, foreign_event_id, foreign_country = rng
max_seats = self.calculate_max_seats_for_range(room, start_date, end_date, existing_seats=seats)
dlg = BookingDialog(room, start_date, end_date, max_seats_available=max_seats,
parent=self, is_edit=True, view_only=view_only)
dlg.client_edit.setText(client)
dlg.training_edit.setText(training or "")
dlg.event_name_edit.setText(event_name or "")
dlg.seats_spin.setValue(seats)
dlg.link_edit.setText(link or "")
dlg.contact_edit.setText(contact_name or "")
dlg.doklad_edit.setText(doklad_link or "")
dlg.moder_edit.setText(moder_link or "")
dlg.event_id_edit.setText(event_id or "")
if time_open:
dlg.time_check.setChecked(True)
dlg.time_open.setTime(QtCore.QTime.fromString(time_open, "HH:mm"))
if time_close:
dlg.time_check.setChecked(True)
dlg.time_close.setTime(QtCore.QTime.fromString(time_close, "HH:mm"))
# Заполняем данные зарубежных слушателей
dlg.foreign_data = {
"has_foreign": has_foreign,
"foreign_country": foreign_country,
"foreign_guest_link": foreign_guest_link,
"foreign_doklad_link": foreign_doklad_link,
"foreign_moder_link": foreign_moder_link,
"foreign_event_id": foreign_event_id,
}
# После заполнения данных повторно применяем режим (текст не должен снимать блокировку)
dlg.set_view_mode(dlg.view_only)
if dlg.exec_() == QtWidgets.QDialog.Accepted:
data = dlg.get_data()
skip_range = (room["id"], start_date, end_date, client, desc, seats, link)
ok, msg = can_add_booking_range(
room, data["date_from"], data["date_to"], data["seats_used"], skip_booking_range=skip_range
)
if not ok:
QtWidgets.QMessageBox.critical(self, "Ошибка", msg)
return
delete_booking_range(room["id"], start_date, end_date, client, desc, seats, link)
ok, msg = add_booking_range(
room=data["room"],
start_date=data["date_from"],
end_date=data["date_to"],
client_name=data["client_name"],
description=data["description"],
seats_used=data["seats_used"],
link=data["link"],
time_open=data["time_open"],
time_close=data["time_close"],
doklad_link=data["doklad_link"],
moder_link=data["moder_link"],
event_id=data["event_id"],
contact_name=data["contact_name"],
training=data["training"],
event_name=data["event_name"],
has_foreign=data["has_foreign"],
foreign_guest_link=data["foreign_guest_link"],
foreign_doklad_link=data["foreign_doklad_link"],
foreign_moder_link=data["foreign_moder_link"],
foreign_event_id=data["foreign_event_id"],
foreign_country=data["foreign_country"],
)
if not ok:
QtWidgets.QMessageBox.critical(self, "Ошибка", msg)
add_booking_range(room, start_date, end_date, client, desc, seats, link,
time_open, time_close, doklad_link, moder_link, event_id, contact_name,
training, event_name)
return
old_data = {
"date_from": start_date, "date_to": end_date,
"client_name": client, "description": desc,
"seats_used": seats, "link": link,
"time_open": time_open, "time_close": time_close,
"doklad_link": doklad_link, "moder_link": moder_link,
"event_id": event_id, "contact_name": contact_name,
"training": training, "event_name": event_name,
"has_foreign": has_foreign,
"foreign_guest_link": foreign_guest_link,
"foreign_doklad_link": foreign_doklad_link,
"foreign_moder_link": foreign_moder_link,
"foreign_event_id": foreign_event_id,
"foreign_country": foreign_country,
}
undo_fn, redo_fn = self._make_edit_actions(room, old_data, data)
self.push_undo(undo_fn, redo_fn)
self.reload_data()
def on_delete_clicked(self):
indexes = self.table.selectedIndexes()
if not indexes:
return
targets = set()
for idx in indexes:
row = idx.row()
col = idx.column()
if 0 <= row < len(self.rooms) and 0 <= col < len(self.days):
targets.add((row, col))
if not targets:
return
has_bookings = False
for row, col in targets:
room = self.rooms[row]
day = self.days[col]
rng = get_booking_range(room["id"], day)
if rng is not None:
has_bookings = True
break
if not has_bookings:
return
reply = QtWidgets.QMessageBox.question(
self, "Удаление",
f"Удалить выбранные бронирования ({len(targets)} шт.)?",
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
if reply != QtWidgets.QMessageBox.Yes:
return
for row, col in targets:
room = self.rooms[row]
day = self.days[col]
rng = get_booking_range(room["id"], day)
if rng:
start_date, end_date, client, desc, seats, link, \
time_open, time_close, guest_link, doklad_link, moder_link, \
event_id, contact_name, training, event_name, \
has_foreign, foreign_guest_link, foreign_doklad_link, \
foreign_moder_link, foreign_event_id, foreign_country = rng
delete_booking_range(room["id"], start_date, end_date, client, desc, seats, link)
undo_fn, redo_fn = self._make_delete_actions(
room, start_date, end_date, client, desc, seats, link
)
self.push_undo(undo_fn, redo_fn)
self.reload_data()
def on_export_excel(self):
if not self.days or not self.rooms:
QtWidgets.QMessageBox.warning(self, "Выгрузка", "Нет данных для выгрузки.")
return
default_name = f"vks_export_{date.today().strftime('%Y%m%d')}.xlsx"
path, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
self, "Сохранить как", default_name,
"Excel (*.xlsx);;CSV (*.csv);;Все файлы (*.*)"
)
if not path:
return
# Определяем формат по расширению; если его нет — по выбранному фильтру
ext = os.path.splitext(path)[1].lower()
if ext not in (".xlsx", ".csv"):
if "CSV" in selected_filter:
path += ".csv"
ext = ".csv"
else:
path += ".xlsx"
ext = ".xlsx"
try:
if ext == ".xlsx":
if not self._export_xlsx(path):
return # openpyxl недоступен — уже показали сообщение
else:
self._export_csv(path)
log.info("Экспорт таблицы: %s", path)
QtWidgets.QMessageBox.information(self, "Выгрузка завершена", f"Данные сохранены в файл:\n{path}")
except Exception as e:
log.error("Ошибка выгрузки: %s", e)
QtWidgets.QMessageBox.critical(self, "Ошибка выгрузки", f"Не удалось сохранить файл:\n{e}")
def _export_csv(self, path):
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f, delimiter=';')
header = ["Комната"] + [d.strftime("%d.%m.%Y") for d in self.days]
writer.writerow(header)
for row, room in enumerate(self.rooms):
row_cells = [room["name"]]
for col, d in enumerate(self.days):
item = self.table.item(row, col)
row_cells.append(item.text() if item is not None else "")
writer.writerow(row_cells)
def _export_xlsx(self, path):
"""Экспорт в настоящий Excel с цветами ячеек и объединением диапазонов.
Возвращает False, если openpyxl не установлен (показывает подсказку)."""
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font, Alignment, Border, Side
from openpyxl.utils import get_column_letter
except ImportError:
reply = QtWidgets.QMessageBox.question(
self, "Excel недоступен",
"Библиотека openpyxl не установлена, поэтому экспорт в .xlsx невозможен.\n"
"Сохранить вместо этого в CSV?\n\n"
"(Установить Excel-поддержку: pip install openpyxl)",
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
if reply == QtWidgets.QMessageBox.Yes:
self._export_csv(os.path.splitext(path)[0] + ".csv")
QtWidgets.QMessageBox.information(
self, "Выгрузка завершена",
f"Данные сохранены в CSV:\n{os.path.splitext(path)[0] + '.csv'}"
)
return False
wb = Workbook()
ws = wb.active
ws.title = "Бронирования"
thin = Side(style="thin", color="BFBFBF")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
wrap_top = Alignment(wrap_text=True, vertical="top", horizontal="left")
center = Alignment(wrap_text=True, vertical="center", horizontal="center")
# Шапка: пустая угловая ячейка + даты
ws.cell(row=1, column=1, value="Комната")
for col, d in enumerate(self.days, start=2):
c = ws.cell(row=1, column=col, value=d.strftime("%d.%m.%Y"))
c.font = Font(bold=True)
c.alignment = center
c.border = border
c.fill = PatternFill("solid", fgColor="D4D4D4")
hcorner = ws.cell(row=1, column=1)
hcorner.font = Font(bold=True)
hcorner.alignment = center
hcorner.border = border
hcorner.fill = PatternFill("solid", fgColor="D4D4D4")
# Строки: название комнаты + ячейки
for row, room in enumerate(self.rooms):
r = row + 2
rc = ws.cell(row=r, column=1, value=room["name"])
rc.font = Font(bold=True)
rc.alignment = Alignment(vertical="center", horizontal="left")
rc.border = border
rc.fill = PatternFill(
"solid",
fgColor=("D6E9FA" if room["type"] == "vks" else "D4F5EB")
)
for col in range(len(self.days)):
item = self.table.item(row, col)
text = item.text() if item is not None else ""
cell = ws.cell(row=r, column=col + 2, value=text)
cell.alignment = wrap_top
cell.border = border
if item is not None:
color = item.background().color()
if color.isValid():
cell.fill = PatternFill("solid", fgColor=color.name().lstrip("#").upper())
# Объединяем ячейки по существующим span'ам таблицы
for row in range(self.table.rowCount()):
for col in range(self.table.columnCount()):
cspan = self.table.columnSpan(row, col)
rspan = self.table.rowSpan(row, col)
if cspan > 1 or rspan > 1:
ws.merge_cells(
start_row=row + 2, start_column=col + 2,
end_row=row + 1 + rspan, end_column=col + 1 + cspan
)
# Ширины колонок + закреплённые области
ws.column_dimensions["A"].width = 16
for col in range(2, len(self.days) + 2):
ws.column_dimensions[get_column_letter(col)].width = 20
ws.freeze_panes = "B2"
wb.save(path)
return True
def on_link_clicked(self):
res = self._get_selected_cell_room_day()
if res is None:
return
room, day = res
rng = get_booking_range(room["id"], day)
if rng is None:
return
# rng[9] = moder_link (ссылка модератора)
moder_link = rng[9]
if not moder_link:
QtWidgets.QMessageBox.information(self, "Ссылка", "Ссылка модератора для этой комнаты не задана.")
return
QtWidgets.QApplication.clipboard().setText(moder_link)
if hasattr(self, "copyStatusLabel"):
self.copyStatusLabel.setText("✓ Ссылка модератора скопирована в буфер обмена")
QtCore.QTimer.singleShot(3000, lambda: self.copyStatusLabel.setText(""))
def on_mail_clicked(self):
res = self._get_selected_cell_room_day()
if res is None:
return
room, day = res
rng = get_booking_range(room["id"], day)
if rng is None:
return
(start_date, end_date, client, desc, seats, link,
time_open, time_close, guest_link, doklad_link, moder_link,
event_id, contact_name, training, event_name,
has_foreign, foreign_guest_link, foreign_doklad_link,
foreign_moder_link, foreign_event_id, foreign_country) = rng
html, plain = build_email(
room_name=room["name"], room_type=room["type"],
start_date=start_date, end_date=end_date, seats=seats,
guest_link=link, time_open=time_open, time_close=time_close,
doklad_link=doklad_link, moder_link=moder_link,
event_id=event_id, contact_name=contact_name,
training=training, event_name=event_name,
)
copy_email_to_clipboard(html, plain)
self.copyStatusLabel.setText("✓ Письмо скопировано в буфер обмена")
QtCore.QTimer.singleShot(3000, lambda: self.copyStatusLabel.setText(""))
def on_cell_double_clicked(self, row, column):
if row < 0 or column < 0 or column >= len(self.days):
return
room = self.rooms[row]
day = self.days[column]
item = self.table.item(row, column)
text = item.text() if item is not None else ""
if text == NO_LICENSE_MARK:
QtWidgets.QMessageBox.information(
self, "Нет лицензий",
f"На {day.strftime('%d.%m.%Y')} нет свободных лицензий для типа «{'ВКС' if room['type'] == 'vks' else 'Вебинар'}».\n"
"Бронирование в эту комнату невозможно."
)
return
if text and text != "свободно":
self.table.clearSelection()
self.table.setCurrentCell(row, column)
self.table.selectionModel().select(
self.table.model().index(row, column),
QtCore.QItemSelectionModel.Select
)
# Двойной клик по бронированию — открываем в режиме ПРОСМОТРА
self._open_booking_editor(room, day, view_only=True)
return
max_seats = self.calculate_max_seats_for_range(room, day, day, existing_seats=0)
dlg = BookingDialog(room, day, day, max_seats_available=max_seats, parent=self)
if dlg.exec_() == QtWidgets.QDialog.Accepted:
data = dlg.get_data()
ok, msg = add_booking_range(
room=data["room"],
start_date=data["date_from"],
end_date=data["date_to"],
client_name=data["client_name"],
description=data["description"],
seats_used=data["seats_used"],
link=data["link"],
time_open=data["time_open"],
time_close=data["time_close"],
doklad_link=data["doklad_link"],
moder_link=data["moder_link"],
event_id=data["event_id"],
contact_name=data["contact_name"],
training=data["training"],
event_name=data["event_name"],
has_foreign=data["has_foreign"],
foreign_guest_link=data["foreign_guest_link"],
foreign_doklad_link=data["foreign_doklad_link"],
foreign_moder_link=data["foreign_moder_link"],
foreign_event_id=data["foreign_event_id"],
foreign_country=data["foreign_country"],
)
if not ok:
QtWidgets.QMessageBox.critical(self, "Ошибка", msg)
return
undo_fn, redo_fn = self._make_add_actions(data["room"], data)
self.push_undo(undo_fn, redo_fn)
self.reload_data()
def on_cell_clicked(self, row, column):
if 0 <= column < len(self.days):
self.update_status_for_day(self.days[column])
def on_selection_changed(self, selected, deselected):
if not self._is_reloading:
QtCore.QTimer.singleShot(0, self.reposition_buttons)
def main():
setup_logging()
log.info("=== Запуск приложения v%s ===", APP_VERSION)
log.info("Путь к базе: %s", DB_PATH)
backup_database()
init_db()
app = QtWidgets.QApplication(sys.argv)
apply_theme(get_saved_theme())
# На Linux предпочитаем PNG, .ico — только как запасной вариант
base = get_app_dir()
icon_path = os.path.join(base, "icon.png")
if not os.path.exists(icon_path):
icon_path = os.path.join(base, "icon.ico")
if os.path.exists(icon_path):
app.setWindowIcon(QtGui.QIcon(icon_path))
# Связывает окно с vks-booking.desktop (иконка в панели задач Astra)
app.setDesktopFileName("vks-booking")
font = QFont("Segoe UI", 10)
font.setStyleHint(QFont.SansSerif)
app.setFont(font)
win = MainWindow()
win.showMaximized()
sys.exit(app.exec_())
if __name__ == "__main__":
main()