Files
1C_Bases/Main_v3.py
T
saturnandClaude 72ce3541f5 Поиск сеансов пользователя по домену и добавление баз прямо в них
При перемещаемых профилях базу нужно писать в профиль на той машине, где у
пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы
узнать, где человек сидит, приходилось отдельно лезть в Dameware.

Что добавлено:
- ad_sessions.py — обнаружение сеансов: список компьютеров из AD через ADSI
  (RSAT не нужен), отсев выключенных по порту 445, опрос WTS, разрешение
  реальной папки профиля через SID и реестр ProfileList, с фоллбэком на
  перебор имён папок, если служба RemoteRegistry отключена.
- Панель «Сеансы пользователя в домене»: поиск по имени, выбор сеансов
  галочками и отдельная кнопка добавления баз в отмеченные сеансы. Старый
  режим записи по профилям остался для пользователей без активных сеансов.
- Отключённые сеансы учитываются наравне с активными: они тоже держат
  профиль загруженным, и запись в серверную копию так же затрётся.
- Фильтр по паре домен + пользователь, чтобы локальные учётки с тем же
  именем не смешивались с доменными.
- Enter в полях ввода запускает соответствующее действие.

Скорость (на парке в 316 машин опрос занимал около 90 секунд):
- WTS вызывается через ctypes, а не pywin32: pywin32 не отпускает GIL на
  время нативного вызова, из-за чего опрос шёл строго последовательно и
  никакие потоки не помогали. На живом домене 21,4 с -> 1,3 с.
- Проверка порта и опрос WTS слиты в один проход, потоков 256.
- Машины, зависшие на RPC, не ждём дольше 4 секунд после того, как очередь
  разобрана: прервать вызов нельзя, но можно перестать его ждать.
- Снимок домена кэшируется на 2 минуты, поэтому поиск следующего
  пользователя мгновенный; для принудительного обновления есть кнопка
  «Пересканировать».
- COM инициализируется явно в каждом потоке: без этого повторное
  сканирование из нового потока падало с MK_E_SYNTAX.

Безопасность записи: перед добавлением сеансы перепроверяются на живость, а
если сеансов не нашлось и при этом часть машин не ответила — выводится
предупреждение, чтобы базу не записали в перемещаемый профиль вслепую.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 17:14:32 +03:00

887 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import threading
import time
import tkinter as tk
from tkinter import messagebox, filedialog
import pyodbc
import uuid
import sys
# Поиск сеансов по домену — необязательная часть: если pywin32 недоступен,
# программа должна продолжать работать в режиме «по профилям».
try:
import ad_sessions
SESSIONS_ERROR = ""
except Exception as _exc:
ad_sessions = None
SESSIONS_ERROR = str(_exc)
_BANNER = ("""
....:::::::::
::::::::::-----::::::
::::------------=--------::..
:::--=--============---==+===-:::.
::---========++=++++======+++++==--:.:
:--==++++++++++++++++++========---==-::.
--==++++++++++++++++++++=====---:::-==--::
:-==+**++++++++++++++++++====------::-==-::
-==+**++++++++++++++++++++=====--------=-::
-=++*++++++++++++++++++++++=========-----::
-=+++++++++++++++++++++++++===========----:
:=++++++++++++++++++++++++++==========---::
-=++++++++++++++++++===+++++===-----====--
-=++++++++++++++++++++==+++++=====-----==-
-++++++++++++++++++++++++++++++++====-----
==+++++++*#####*******++++++++++++++++=--=
+===++++++****##%%%%##*****++++**###%##*+==-
+*++=++++****#%##%@%%%%###***++*#%%%%%###*===-
+*++=++++**####*#%@%#%%%##**++*#%%%@@%+*%%*+=-
*++=+++++**+****####%%##*++==+#%%#%%#+++*+==-
+*+++++++++++****###****++=====*####*++=====-
+*=+++++++++++++++++++++++=======+**++=-----
+*=+++++++++++++++++***++++==-=====+++==----
++=+++++++++++++++*****+++==---===========--
+=+++++++++++++*****+++++===---++======--=-
==++++++++++**********+++++==--+++++=====-
-==+++++++++*********##*******==++++++===-
-==++++++++*****#**##*####**+====++++++==
#%%%%%===+++++++++***##*****####**++++++==+++=-
*+*#%%%#*+====++++++++**#%%%##****##********+====-
++++++*##%@%#+++**====+++++++*##############***###*+====-
++******###%%@@@@@%####*+=+==+++++**************+++++=+*++===-
#***+**#*###%@@@@@@@@@@@@@%###*+=+==++++*****++*********++==++++=-=+=+=-
#*#%%%###%%@@@@@@@@@@@@@@@@%###*++++++++**+************+++========*****++==-
%%%######%%####%@@@@@@@@@@@@@@%%**+++++++++++++++***+++=========*###******+++++==
%%@@@@%%%%%%%%%#%%@@@@@@@@@@@@@@%#**+*+**++++++++++++++++=====+%###########***++++*
%@@@@@@@@@%%@@%%%%%%%@@@@@@@@@@@@@@###*****++++++*+++++++===+**#%####%###%####***+***
%%%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@%####**++++***++++*#####*%##%####%#%%%%##*******
%%%%@%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@%%%%%%##%%%@%%####*%%#%####%#%%%%%#########
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%##%#*#%#%####%##%%%%%%###%#%##
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%###%######%##%#%%%%%%%%%%%##
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%####%%#####%##%%%%%%%%%%%%%###
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%####%%#%%##%#%%%%%%%%%%@%@%###
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%#####%%%#%%%%#%%%%%%%@@@@@@@%%##
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%#%%%%##%%#%%%%%%%%%%%%@@@@@@@%%#**
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%@@@%####%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@@@@@@%%%#**
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@@@@%%%%%%%#%%%%%%%##%%%%%%%%%%%%%%%%%@@@@@@%%%%##**
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%@@@@%%@@@@@@@%%%%%%##%%%%%%%###%%%%%%%%%%%%%%%%%@@%%%%%%%%##*+
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@%%%%@@@@@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%###%%*#
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@%@@@%@@@@@@@%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%
""")
# В оконном режиме (сборка без консоли) sys.stdout может быть None — не роняем старт
if getattr(sys, "stdout", None):
try:
print(_BANNER)
except Exception:
pass
class RemoteFolderSelector(tk.Tk):
def __init__(self):
super().__init__()
self.title("Добавление баз 1С пользователям")
self.geometry("1180x820")
self.minsize(1040, 700)
# --- Состояние ---
self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК
self.active_user = None # пользователь, чьи базы показаны в панели "Подключённые базы"
self.user_vars = {} # имя пользователя -> BooleanVar (галочка)
self.db_vars = {} # имя базы из SQL -> BooleanVar (галочка)
self.conn_vars = {} # имя подключённой базы активного пользователя -> BooleanVar
self.session_vars = {} # подпись сеанса -> BooleanVar (галочка)
self.sessions_by_key = {} # подпись сеанса -> словарь с данными сеанса
self.scanning = False # идёт ли сейчас поиск сеансов
self.scan_cache = None # последний снимок сеансов всего домена
self.scan_cache_at = 0.0 # время снимка (time.monotonic)
self.scan_cache_stats = {} # статистика последнего скана
# Компоновка окна: три колонки сверху, кнопки действий снизу
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
self.grid_columnconfigure(2, weight=1)
self.grid_rowconfigure(0, weight=1)
self.frame_left = tk.Frame(self)
self.frame_left.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
self.frame_mid = tk.Frame(self)
self.frame_mid.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
self.frame_right = tk.Frame(self)
self.frame_right.grid(row=0, column=2, padx=10, pady=10, sticky="nsew")
self._build_left_panel()
self._build_sessions_panel()
self._build_right_panel()
# Кнопки действий снизу, на всю ширину окна
actions = tk.Frame(self)
actions.grid(row=1, column=0, columnspan=3, padx=10, pady=(0, 12), sticky="ew")
actions.grid_columnconfigure(0, weight=1)
actions.grid_columnconfigure(1, weight=1)
self.add_session_button = tk.Button(
actions, text="Добавить базы в отмеченные сеансы",
font=("Segoe UI", 11, "bold"), command=self.add_bases_to_sessions
)
self.add_session_button.grid(row=0, column=0, padx=(0, 5), sticky="ew")
self.add_button = tk.Button(
actions, text="Добавить базы отмеченным пользователям (профили)",
font=("Segoe UI", 11), command=self.add_database_to_ibases
)
self.add_button.grid(row=0, column=1, padx=(5, 0), sticky="ew")
# По умолчанию грузим пользователей локального ПК
self.load_users()
# ------------------------------------------------------------------ UI
def _build_left_panel(self):
f = self.frame_left
f.grid_columnconfigure(0, weight=1)
tk.Label(f, text="IP-адрес (необязательно):").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
self.ip_entry = tk.Entry(f)
self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
self.ip_entry.bind("<Return>", lambda e: self.load_users())
tk.Button(f, text="Загрузить пользователей", command=self.load_users)\
.grid(row=2, column=0, padx=5, pady=5, sticky="ew")
# Поиск по пользователям
tk.Label(f, text="Поиск пользователей:").grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w")
self.user_search_var = tk.StringVar()
self.user_search_var.trace_add("write", lambda *a: self._render_checklist(
self.user_inner, self.user_vars, self.user_search_var.get(), self.on_user_click))
tk.Entry(f, textvariable=self.user_search_var)\
.grid(row=4, column=0, padx=5, pady=(0, 5), sticky="ew")
# Отметить все / снять все (с учётом фильтра)
bar = tk.Frame(f)
bar.grid(row=5, column=0, padx=5, pady=(0, 3), sticky="w")
tk.Button(bar, text="Отметить все",
command=lambda: self._set_all(self.user_vars, self.user_search_var.get(), True))\
.pack(side="left", padx=(0, 4))
tk.Button(bar, text="Снять все",
command=lambda: self._set_all(self.user_vars, self.user_search_var.get(), False))\
.pack(side="left")
# Список пользователей
self.user_container, self.user_inner = self._create_scrollable_checklist(f, height=150)
self.user_container.grid(row=6, column=0, padx=5, pady=5, sticky="nsew")
f.grid_rowconfigure(6, weight=1)
# Панель подключённых баз активного пользователя
self.conn_label = tk.Label(f, text="Подключённые базы: (выберите пользователя)")
self.conn_label.grid(row=7, column=0, padx=5, pady=(8, 0), sticky="w")
bar2 = tk.Frame(f)
bar2.grid(row=8, column=0, padx=5, pady=(0, 3), sticky="w")
tk.Button(bar2, text="Отметить все",
command=lambda: self._set_all(self.conn_vars, "", True)).pack(side="left", padx=(0, 4))
tk.Button(bar2, text="Снять все",
command=lambda: self._set_all(self.conn_vars, "", False)).pack(side="left")
self.conn_container, self.conn_inner = self._create_scrollable_checklist(f, height=150)
self.conn_container.grid(row=9, column=0, padx=5, pady=5, sticky="nsew")
f.grid_rowconfigure(9, weight=1)
tk.Button(f, text="Удалить отмеченные базы у пользователя", command=self.delete_connected_bases)\
.grid(row=10, column=0, padx=5, pady=5, sticky="ew")
# Опция дублирования в ZIP (roaming) профиль
self.zip_enabled = tk.BooleanVar(value=False)
tk.Checkbutton(f, text="Дублировать изменения в ZIP-профиль пользователя",
variable=self.zip_enabled, command=self._toggle_zip)\
.grid(row=11, column=0, padx=5, pady=(8, 0), sticky="w")
zf = tk.Frame(f)
zf.grid(row=12, column=0, padx=5, pady=(0, 5), sticky="ew")
zf.grid_columnconfigure(0, weight=1)
self.zip_path_var = tk.StringVar(value=r"Z:\ZIP_User_Profiles")
self.zip_path_entry = tk.Entry(zf, textvariable=self.zip_path_var, state="disabled")
self.zip_path_entry.grid(row=0, column=0, sticky="ew", padx=(0, 4))
self.zip_browse_btn = tk.Button(zf, text="Обзор...", state="disabled", command=self._browse_zip)
self.zip_browse_btn.grid(row=0, column=1)
def _build_right_panel(self):
f = self.frame_right
f.grid_columnconfigure(0, weight=1)
tk.Label(f, text="SQL-сервер:").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
self.server_entry = tk.Entry(f)
self.server_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
self.server_entry.insert(0, "SERVERSQL")
tk.Label(f, text="Логин SQL:").grid(row=2, column=0, padx=5, pady=(5, 0), sticky="w")
self.login_entry = tk.Entry(f)
self.login_entry.grid(row=3, column=0, padx=5, pady=(0, 5), sticky="ew")
self.login_entry.insert(0, "sa")
tk.Label(f, text="Пароль SQL:").grid(row=4, column=0, padx=5, pady=(5, 0), sticky="w")
self.password_entry = tk.Entry(f, show="*")
self.password_entry.grid(row=5, column=0, padx=5, pady=(0, 5), sticky="ew")
# Enter в любом из полей подключения — сразу загружаем базы
for entry in (self.server_entry, self.login_entry, self.password_entry):
entry.bind("<Return>", lambda e: self.load_databases())
tk.Button(f, text="Загрузить базы", command=self.load_databases)\
.grid(row=6, column=0, padx=5, pady=5, sticky="ew")
# Поиск по базам
tk.Label(f, text="Поиск баз:").grid(row=7, column=0, padx=5, pady=(5, 0), sticky="w")
self.db_search_var = tk.StringVar()
self.db_search_var.trace_add("write", lambda *a: self._render_checklist(
self.db_inner, self.db_vars, self.db_search_var.get()))
tk.Entry(f, textvariable=self.db_search_var)\
.grid(row=8, column=0, padx=5, pady=(0, 5), sticky="ew")
bar = tk.Frame(f)
bar.grid(row=9, column=0, padx=5, pady=(0, 3), sticky="w")
tk.Button(bar, text="Отметить все",
command=lambda: self._set_all(self.db_vars, self.db_search_var.get(), True))\
.pack(side="left", padx=(0, 4))
tk.Button(bar, text="Снять все",
command=lambda: self._set_all(self.db_vars, self.db_search_var.get(), False))\
.pack(side="left")
self.db_container, self.db_inner = self._create_scrollable_checklist(f, height=380)
self.db_container.grid(row=10, column=0, padx=5, pady=5, sticky="nsew")
f.grid_rowconfigure(10, weight=1)
def _build_sessions_panel(self):
"""Панель поиска активных сеансов пользователя по домену."""
f = self.frame_mid
f.grid_columnconfigure(0, weight=1)
tk.Label(f, text="Сеансы пользователя в домене", font=("Segoe UI", 10, "bold"))\
.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
tk.Label(f, text="Имя пользователя:").grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w")
self.session_user_var = tk.StringVar()
self.session_user_entry = tk.Entry(f, textvariable=self.session_user_var)
self.session_user_entry.grid(row=2, column=0, padx=5, pady=(0, 5), sticky="ew")
self.session_user_entry.bind("<Return>", lambda e: self.find_sessions())
scan_bar = tk.Frame(f)
scan_bar.grid(row=3, column=0, padx=5, pady=5, sticky="ew")
scan_bar.grid_columnconfigure(0, weight=3)
scan_bar.grid_columnconfigure(1, weight=2)
self.scan_button = tk.Button(scan_bar, text="Найти сеансы", command=self.find_sessions)
self.scan_button.grid(row=0, column=0, padx=(0, 4), sticky="ew")
# Принудительный повторный опрос домена, когда данные скана устарели
self.rescan_button = tk.Button(scan_bar, text="Пересканировать",
command=lambda: self.find_sessions(force=True))
self.rescan_button.grid(row=0, column=1, sticky="ew")
# Отключённый сеанс тоже держит профиль загруженным, поэтому по умолчанию учитываем и его
self.include_disconnected = tk.BooleanVar(value=True)
tk.Checkbutton(f, text="Учитывать отключённые сеансы", variable=self.include_disconnected)\
.grid(row=4, column=0, padx=5, pady=(0, 3), sticky="w")
self.scan_status = tk.Label(f, text="", anchor="w", justify="left",
fg="#444", wraplength=320)
self.scan_status.grid(row=5, column=0, padx=5, pady=(0, 3), sticky="ew")
bar = tk.Frame(f)
bar.grid(row=6, column=0, padx=5, pady=(0, 3), sticky="w")
tk.Button(bar, text="Отметить все",
command=lambda: self._set_all(self.session_vars, "", True)).pack(side="left", padx=(0, 4))
tk.Button(bar, text="Снять все",
command=lambda: self._set_all(self.session_vars, "", False)).pack(side="left")
self.sess_container, self.sess_inner = self._create_scrollable_checklist(f, height=330)
self.sess_container.grid(row=7, column=0, padx=5, pady=5, sticky="nsew")
f.grid_rowconfigure(7, weight=1)
tk.Label(f, text="Базы добавляются в профиль на той машине,\nгде найден сеанс — "
"чтобы правку не затёрло\nпри выходе из перемещаемого профиля.",
anchor="w", justify="left", fg="#666")\
.grid(row=8, column=0, padx=5, pady=(3, 0), sticky="ew")
if ad_sessions is None:
self.scan_button.config(state="disabled")
self.session_user_entry.config(state="disabled")
self.scan_status.config(
text=f"Поиск сеансов недоступен: не загружен модуль ({SESSIONS_ERROR})", fg="#a00")
def _create_scrollable_checklist(self, parent, width=220, height=180):
"""Прокручиваемая область для чекбоксов. Возвращает (container, inner)."""
container = tk.Frame(parent, borderwidth=1, relief="sunken")
canvas = tk.Canvas(container, width=width, height=height, highlightthickness=0)
scrollbar = tk.Scrollbar(container, orient="vertical", command=canvas.yview)
inner = tk.Frame(canvas)
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
def _on_mousewheel(event):
canvas.yview_scroll(int(-event.delta / 120), "units")
canvas.bind("<Enter>", lambda e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))
return container, inner
def _load_items(self, vars_dict, items):
"""Создаём свежий набор BooleanVar по списку items (галочки сбрасываются)."""
vars_dict.clear()
for item in items:
vars_dict[item] = tk.BooleanVar(value=False)
def _render_checklist(self, inner, vars_dict, filter_text="", on_click=None):
"""Рисуем чекбоксы, показывая только элементы, подходящие под фильтр.
Переменные (галочки) сохраняются между перерисовками — состояние не теряется."""
for child in inner.winfo_children():
child.destroy()
ft = (filter_text or "").lower()
for name, var in vars_dict.items():
if ft and ft not in name.lower():
continue
cmd = (lambda n=name: on_click(n)) if on_click else None
tk.Checkbutton(inner, text=name, variable=var, anchor="w", command=cmd)\
.pack(fill="x", anchor="w")
# После перерисовки (например, при вводе в поиск) возвращаем прокрутку наверх,
# иначе список остаётся проскроллен вниз и результат поиска не виден.
inner.master.yview_moveto(0)
def _set_all(self, vars_dict, filter_text, value):
"""Отмечаем/снимаем все чекбоксы, попадающие под текущий фильтр."""
ft = (filter_text or "").lower()
for name, var in vars_dict.items():
if ft and ft not in name.lower():
continue
var.set(value)
def _get_checked(self, vars_dict):
return [name for name, var in vars_dict.items() if var.get()]
def _toggle_zip(self):
state = "normal" if self.zip_enabled.get() else "disabled"
self.zip_path_entry.config(state=state)
self.zip_browse_btn.config(state=state)
def _browse_zip(self):
path = filedialog.askdirectory(title="Каталог с ZIP-профилями пользователей")
if path:
self.zip_path_var.set(os.path.normpath(path))
# -------------------------------------------------------------- Загрузка
def load_users(self):
"""Загружаем список пользователей с локального ПК или с удалённого по IP."""
self.ip_address = self.ip_entry.get().strip()
if not self.ip_address:
base = r"C:\Users"
else:
base = f"\\\\{self.ip_address}\\C$\\Users"
try:
folders = [f for f in os.listdir(base) if os.path.isdir(os.path.join(base, f))]
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить пользователей:\n{e}")
return
self._load_items(self.user_vars, folders)
self._render_checklist(self.user_inner, self.user_vars, self.user_search_var.get(), self.on_user_click)
# Сбрасываем панель подключённых баз — она относилась к прежнему источнику
self.active_user = None
self.conn_vars.clear()
self._render_checklist(self.conn_inner, self.conn_vars, "")
self.conn_label.config(text="Подключённые базы: (выберите пользователя)")
def load_databases(self):
"""Подключаемся к SQL-серверу и загружаем список баз."""
server = self.server_entry.get().strip()
login = self.login_entry.get()
password = self.password_entry.get()
try:
connection_string = f"DRIVER={{SQL Server}};SERVER={server};UID={login};PWD={password};"
connection = pyodbc.connect(connection_string)
cursor = connection.cursor()
cursor.execute("SELECT name FROM sys.databases")
databases = cursor.fetchall()
cursor.close()
connection.close()
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось подключиться к SQL-серверу:\n{e}")
return
# Служебные базы SQL Server скрываем — это не базы 1С
system_dbs = {"master", "tempdb", "model", "msdb"}
names = [db[0] for db in databases if db[0].lower() not in system_dbs]
self._load_items(self.db_vars, names)
self._render_checklist(self.db_inner, self.db_vars, self.db_search_var.get())
def on_user_click(self, user):
"""Клик по пользователю — делаем его активным и показываем его подключённые базы.
Заодно подставляем имя в поле поиска сеансов, чтобы не набирать вручную."""
self.load_connected_bases(user)
if ad_sessions is not None:
self.session_user_var.set(user)
def load_connected_bases(self, user):
"""Читаем ibases.v8i пользователя и показываем список его баз в панели."""
self.active_user = user
self.ip_address = self.ip_entry.get().strip()
path = self.get_ibases_path(user)
text = self._read_ibases(path)
_, sections = self._parse_ibases(text)
names = [name for name, _ in sections]
self._load_items(self.conn_vars, names)
self._render_checklist(self.conn_inner, self.conn_vars, "")
suffix = "" if names else " (баз нет)"
self.conn_label.config(text=f"Подключённые базы: {user}{suffix}")
# --------------------------------------------------------------- Пути
def get_ibases_path(self, user):
"""Путь к ibases.v8i пользователя (локально либо по IP)."""
if not self.ip_address:
base_path = f"C:\\Users\\{user}\\AppData\\Roaming\\1C\\1CEStart"
else:
base_path = f"\\\\{self.ip_address}\\C$\\Users\\{user}\\AppData\\Roaming\\1C\\1CEStart"
return os.path.join(base_path, "ibases.v8i")
def get_zip_ibases_path(self, user):
"""Путь к ibases.v8i внутри ZIP-профиля пользователя.
Папку профиля ищем автопоиском: <user> либо <user>.* (например <user>.V6).
Возвращаем None, если корень недоступен или профиль не найден."""
root = self.zip_path_var.get().strip()
if not root or not os.path.isdir(root):
return None
u = user.lower()
candidates = []
try:
for name in os.listdir(root):
if not os.path.isdir(os.path.join(root, name)):
continue
low = name.lower()
if low == u or low.startswith(u + "."):
candidates.append(name)
except Exception:
return None
if not candidates:
return None
# Приоритет: точное <user>.V6, затем любой суффикс .*, затем совпадение без суффикса
def score(n):
low = n.lower()
if low == u + ".v6":
return 0
if "." in low:
return 1
return 2
candidates.sort(key=score)
chosen = candidates[0]
return os.path.join(root, chosen, "AppData", "Roaming", "1C", "1CEStart", "ibases.v8i")
# ------------------------------------------------------- Чтение/запись v8i
def _read_ibases(self, path):
"""Читаем ibases.v8i, устойчиво к кодировке (UTF-8 с BOM / без / cp1251)."""
if not os.path.exists(path):
return ""
for enc in ("utf-8-sig", "cp1251"):
try:
with open(path, "r", encoding=enc) as f:
return f.read()
except UnicodeDecodeError:
continue
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def _parse_ibases(self, text):
"""Разбираем содержимое на (преамбула, [(имя_базы, блок_текста), ...]).
Секция начинается со строки вида [ИмяБазы]."""
header_re = re.compile(r"^\s*\[(.+?)\]\s*$")
preamble = []
sections = [] # список [имя, [строки]]
current = None
for line in text.splitlines(keepends=True):
m = header_re.match(line)
if m:
current = [m.group(1), [line]]
sections.append(current)
elif current is None:
preamble.append(line)
else:
current[1].append(line)
return "".join(preamble), [(name, "".join(ls)) for name, ls in sections]
def build_base_entry(self, base, server):
"""Формируем запись одной базы для ibases.v8i."""
return (
f"\n[{base}]\n"
f'Connect=Srvr="{server}";Ref="{base}";\n'
f"ID={uuid.uuid4()}\n"
"OrderInList=255\n"
"Folder=/\n"
"OrderInTree=16640\n"
"External=0\n"
"ClientConnectionSpeed=Normal\n"
"App=Auto\n"
"WA=1\n"
"Version=8.3\n"
"DefaultApp=ThickClient\n"
"DisableLocalSpeechToText=0\n"
)
def _add_entries_to_file(self, ibases_path, bases, server):
"""Добавляем базы в один файл ibases.v8i (с фильтром дублей).
Возвращаем (добавлено, пропущено)."""
base_dir = os.path.dirname(ibases_path)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
existing = self._read_ibases(ibases_path)
entries = []
added = skipped = 0
for base in bases:
connect_line = f'Connect=Srvr="{server}";Ref="{base}";'
if connect_line in existing or any(connect_line in e for e in entries):
skipped += 1
continue
entries.append(self.build_base_entry(base, server))
added += 1
if entries:
with open(ibases_path, "a", encoding="utf-8") as f:
f.write("".join(entries))
return added, skipped
def _delete_entries_from_file(self, ibases_path, base_names):
"""Удаляем указанные базы из одного файла ibases.v8i. Возвращаем число удалённых."""
if not os.path.exists(ibases_path):
return 0
text = self._read_ibases(ibases_path)
preamble, sections = self._parse_ibases(text)
target = {b.lower() for b in base_names}
kept = [blk for (name, blk) in sections if name.lower() not in target]
removed = len(sections) - len(kept)
if removed:
with open(ibases_path, "w", encoding="utf-8") as f:
f.write(preamble + "".join(kept))
return removed
# ----------------------------------------------------------- Действия
def add_database_to_ibases(self):
"""Добавляем отмеченные базы всем отмеченным пользователям (+ ZIP по галочке)."""
self.ip_address = self.ip_entry.get().strip()
users = self._get_checked(self.user_vars)
bases = self._get_checked(self.db_vars)
server = self.server_entry.get().strip()
if not users:
messagebox.showerror("Ошибка", "Не отмечен ни один пользователь!")
return
if not bases:
messagebox.showerror("Ошибка", "Не отмечена ни одна база!")
return
if not server:
messagebox.showerror("Ошибка", "Не указан SQL-сервер!")
return
use_zip = self.zip_enabled.get()
added = skipped = zadded = zskipped = 0
errors = []
for user in users:
try:
a, s = self._add_entries_to_file(self.get_ibases_path(user), bases, server)
added += a
skipped += s
except Exception as e:
errors.append(f"{user}: {e}")
if use_zip:
zp = self.get_zip_ibases_path(user)
if zp is None:
errors.append(f"{user}: ZIP-профиль не найден")
else:
try:
a, s = self._add_entries_to_file(zp, bases, server)
zadded += a
zskipped += s
except Exception as e:
errors.append(f"{user} (ZIP): {e}")
# Обновим панель подключённых баз, если активный пользователь среди затронутых
if self.active_user in users:
self.load_connected_bases(self.active_user)
summary = (
f"Пользователей: {len(users)}, баз отмечено: {len(bases)}\n"
f"Локально — добавлено: {added}, пропущено (уже есть): {skipped}"
)
if use_zip:
summary += f"\nZIP-профили — добавлено: {zadded}, пропущено: {zskipped}"
self._report(summary, errors)
def delete_connected_bases(self):
"""Удаляем отмеченные подключённые базы у активного пользователя (+ ZIP по галочке)."""
user = self.active_user
if not user:
messagebox.showerror("Ошибка", "Сначала выберите пользователя слева.")
return
bases = self._get_checked(self.conn_vars)
if not bases:
messagebox.showerror("Ошибка", "Не отмечена ни одна база для удаления!")
return
if not messagebox.askyesno(
"Подтверждение",
f"Удалить {len(bases)} баз(ы) у пользователя «{user}»?\n\n" + "\n".join(bases)
):
return
self.ip_address = self.ip_entry.get().strip()
use_zip = self.zip_enabled.get()
removed_local = removed_zip = 0
errors = []
try:
removed_local = self._delete_entries_from_file(self.get_ibases_path(user), bases)
except Exception as e:
errors.append(f"{user}: {e}")
if use_zip:
zp = self.get_zip_ibases_path(user)
if zp is None:
errors.append(f"{user}: ZIP-профиль не найден")
else:
try:
removed_zip = self._delete_entries_from_file(zp, bases)
except Exception as e:
errors.append(f"{user} (ZIP): {e}")
self.load_connected_bases(user)
summary = f"Пользователь: {user}\nУдалено локально: {removed_local}"
if use_zip:
summary += f"\nУдалено в ZIP-профиле: {removed_zip}"
self._report(summary, errors)
# ------------------------------------------------------- Сеансы в домене
# Сколько секунд снимок домена считается свежим. За это время поиск другого
# пользователя отрабатывает мгновенно, без повторного опроса машин.
SCAN_CACHE_TTL = 120
def find_sessions(self, force=False):
"""Ищем сеансы пользователя. Если снимок домена свежий — фильтруем его
мгновенно; иначе опрашиваем домен в фоне, чтобы окно не подвисало."""
if ad_sessions is None or self.scanning:
return
user = self.session_user_var.get().strip()
if not user:
messagebox.showerror("Ошибка", "Укажите имя пользователя для поиска сеансов.")
return
domain = ad_sessions.current_domain()
include_disc = self.include_disconnected.get()
age = time.monotonic() - self.scan_cache_at
if not force and self.scan_cache is not None and age < self.SCAN_CACHE_TTL:
matched = ad_sessions.filter_sessions(
self.scan_cache, user=user, domain=domain,
include_disconnected=include_disc)
stats = dict(self.scan_cache_stats)
stats["matched"] = len(matched)
stats["from_cache"] = True
stats["cache_age"] = int(age)
self._on_sessions_found(user, matched, [], stats, None)
return
self.scanning = True
self.scan_button.config(state="disabled", text="Идёт поиск...")
self.rescan_button.config(state="disabled")
self.scan_status.config(text="Получаем список компьютеров из AD...", fg="#444")
self.session_vars.clear()
self.sessions_by_key.clear()
self._render_checklist(self.sess_inner, self.session_vars, "")
def progress(stage, done, total):
text = f"{stage} {done}/{total}" if total else stage
self.after(0, lambda: self.scan_status.config(text=text))
def worker():
try:
# Снимаем сеансы всех пользователей разом и кладём в кэш —
# следующий поиск по другому имени будет мгновенным
everyone, errors, stats = ad_sessions.scan_domain(progress=progress)
matched = ad_sessions.filter_sessions(
everyone, user=user, domain=domain,
include_disconnected=include_disc)
stats["matched"] = len(matched)
self.after(0, self._store_scan_cache, everyone, stats)
self.after(0, self._on_sessions_found, user, matched, errors, stats, None)
except Exception as exc:
self.after(0, self._on_sessions_found, user, [], [], {},
f"{type(exc).__name__}: {exc}")
threading.Thread(target=worker, daemon=True).start()
def _store_scan_cache(self, sessions, stats):
self.scan_cache = sessions
self.scan_cache_at = time.monotonic()
self.scan_cache_stats = dict(stats)
def _on_sessions_found(self, user, sessions, errors, stats, fatal):
"""Обработка результата фонового сканирования (вызывается в потоке GUI)."""
self.scanning = False
self.scan_button.config(state="normal", text="Найти сеансы")
self.rescan_button.config(state="normal")
if fatal:
self.scan_status.config(text=f"Ошибка поиска: {fatal}", fg="#a00")
messagebox.showerror("Ошибка", f"Не удалось выполнить поиск сеансов:\n{fatal}")
return
# Чистим здесь, а не только перед сканированием: поиск по кэшу приходит
# сюда напрямую, и без этого результаты прошлого поиска накапливались бы
self.session_vars.clear()
self.sessions_by_key.clear()
for sess in sessions:
machine = ad_sessions.short_name(sess["machine"])
key = f"{machine} (сеанс {sess['session_id']}, {sess['state_name']})"
if sess.get("client"):
key += f" ← {sess['client']}"
self.sessions_by_key[key] = sess
self.session_vars[key] = tk.BooleanVar(value=True) # найденное сразу отмечено
self._render_checklist(self.sess_inner, self.session_vars, "")
total = stats.get("total", 0)
alive = stats.get("alive", 0)
failed = stats.get("failed", 0)
unfinished = stats.get("unfinished", 0)
if sessions:
head = f"Найдено сеансов: {len(sessions)} у «{user}»."
color = "#060"
else:
head = f"Сеансов пользователя «{user}» не найдено."
color = "#a60"
if stats.get("from_cache"):
tail = (f"Данные скана {stats.get('cache_age', 0)} с назад "
f"(машин: {total}, с сеансами: {alive}). "
f"Кнопка «Пересканировать» обновит.")
else:
tail = (f"Машин: {total}, ответили: {alive}, "
f"выключено: {stats.get('offline', 0)}. "
f"Время: {stats.get('scan_seconds', 0)} с "
f"(AD {stats.get('ad_seconds', 0)} с).")
if failed:
tail += f" Ошибок: {failed}."
if unfinished:
tail += f" Не успели за отведённое время: {unfinished}."
self.scan_status.config(text=head + " " + tail, fg=color)
# Если сеансов не нашлось, а часть машин не опрошена — предупреждаем явно.
# Иначе админ решит, что пользователь нигде не залогинен, добавит базу
# в перемещаемый профиль, и её затрёт при выходе из пропущенного сеанса.
if not sessions and (failed or unfinished) and not stats.get("from_cache"):
details = ""
if errors:
details = "\n\n" + "\n".join(f"{host}: {err}" for host, err in errors[:8])
messagebox.showwarning(
"Опрошены не все машины",
f"Сеансов пользователя «{user}» не найдено, но "
f"{failed + unfinished} машин(ы) не ответили.\n\n"
"Пользователь может сидеть на одной из них. Прежде чем писать "
"в перемещаемый профиль, нажмите «Пересканировать»."
+ details)
def add_bases_to_sessions(self):
"""Добавляем отмеченные базы в профили на машинах отмеченных сеансов."""
if ad_sessions is None:
messagebox.showerror("Ошибка", "Модуль поиска сеансов недоступен.")
return
keys = self._get_checked(self.session_vars)
bases = self._get_checked(self.db_vars)
server = self.server_entry.get().strip()
if not keys:
messagebox.showerror("Ошибка", "Не отмечен ни один сеанс!")
return
if not bases:
messagebox.showerror("Ошибка", "Не отмечена ни одна база!")
return
if not server:
messagebox.showerror("Ошибка", "Не указан SQL-сервер!")
return
chosen = [self.sessions_by_key[key] for key in keys]
# Список сеансов мог быть снят пару минут назад — убеждаемся, что они ещё живы.
# Машин тут единицы, проверка почти мгновенная.
chosen, gone = ad_sessions.verify_sessions(chosen)
if gone:
names = "\n".join(
f"{ad_sessions.short_name(s['machine'])} (сеанс {s['session_id']})"
for s in gone)
if not chosen:
messagebox.showerror(
"Сеансы завершены",
"Выбранные сеансы уже завершены — пользователь вышел:\n\n"
f"{names}\n\nНажмите «Пересканировать» и попробуйте снова.")
return
if not messagebox.askyesno(
"Часть сеансов завершена",
f"Эти сеансы уже завершены и будут пропущены:\n\n{names}\n\n"
"Продолжить с остальными?"):
return
# На одной машине у пользователя может быть несколько сеансов (например,
# активный и отключённый), а профиль у них общий — пишем в машину один раз.
by_machine = {}
for sess in chosen:
by_machine.setdefault(sess["machine"], sess)
added = skipped = 0
done_machines = []
errors = []
for machine, sess in by_machine.items():
try:
path = ad_sessions.ibases_path_for_session(sess)
a, s = self._add_entries_to_file(path, bases, server)
added += a
skipped += s
done_machines.append(f"{ad_sessions.short_name(machine)}: +{a}, пропущено {s}")
except Exception as exc:
errors.append(f"{ad_sessions.short_name(machine)}: {exc}")
summary = (
f"Пользователь: {self.session_user_var.get().strip()}\n"
f"Машин обработано: {len(done_machines)} из {len(by_machine)}, "
f"баз отмечено: {len(bases)}\n"
f"Добавлено: {added}, пропущено (уже есть): {skipped}"
)
if done_machines:
summary += "\n\n" + "\n".join(done_machines)
self._report(summary, errors)
def _report(self, summary, errors):
if errors:
messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors))
else:
messagebox.showinfo("Готово", summary)
if __name__ == "__main__":
app = RemoteFolderSelector()
app.mainloop()