fix: 8 багов — город, IS поля, ПО, авторасчёт, скролл, прогресc, комиссия, замены

This commit is contained in:
2026-07-30 14:38:47 +04:00
parent 7eb736bcbf
commit 30cb3f66bf
5 changed files with 210 additions and 38 deletions
+56 -8
View File
@@ -57,16 +57,19 @@ def process_template(
buf.seek(0)
doc = Document(buf)
# 5. Плоская замена переменных (2 прохода)
for _ in range(2):
# 5. Плоская замена переменных (3 прохода)
for _ in range(3):
_replace_in_paragraphs(doc.paragraphs, replacements)
_replace_in_tables(doc.tables, replacements)
_replace_in_headers_footers(doc, replacements)
# 6. Подсветка незаполненных
# 6. Логирование незаменённых переменных
_log_unmatched(doc, replacements, log_func if log_func else lambda x: None)
# 7. Подсветка незаполненных
_highlight_unmatched(doc)
# 7. Сохранение
# 8. Сохранение
doc.save(output_path)
if log_func:
@@ -338,12 +341,57 @@ def _find_element_bounds(xml_str: str, pos: int):
return None, None
# ==================== ЛОГИРОВАНИЕ НЕЗАМЕНЁННЫХ ====================
UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>')
def _log_unmatched(doc, replacements: Dict[str, str], log_func):
"""Логирует оставшиеся незаменённые переменные."""
unmatched = set()
def _scan_text(text):
for m in UNMATCHED_PATTERN.finditer(text):
var = m.group(0)
# Проверяем, не является ли это уже обработанной переменной
if var.startswith('{{'):
if var not in replacements:
unmatched.add(var)
elif var.startswith('<'):
if var not in replacements:
unmatched.add(var)
for para in doc.paragraphs:
_scan_text(para.text)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
_scan_text(para.text)
for section in doc.sections:
for hf in [section.header, section.footer,
section.first_page_header, section.first_page_footer]:
if hf:
for para in hf.paragraphs:
_scan_text(para.text)
if unmatched:
log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): {', '.join(sorted(unmatched)[:20])}")
if len(unmatched) > 20:
log_func(f" ... и ещё {len(unmatched) - 20}")
else:
log_func("✅ Все переменные успешно заменены")
# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ====================
def _highlight_unmatched(doc):
"""Подсвечивает незаполненные переменные красным."""
"""Подсвечивает незаполненные переменные красным ({{...}} и старые <...>)."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _has_unmatched(text):
return bool(UNMATCHED_PATTERN.search(text))
def _highlight_run(run):
rpr = run._element.find(f'{{{ns}}}rPr')
if rpr is None:
@@ -358,7 +406,7 @@ def _highlight_unmatched(doc):
for para in doc.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
if _has_unmatched(run.text):
_highlight_run(run)
for table in doc.tables:
@@ -366,7 +414,7 @@ def _highlight_unmatched(doc):
for cell in row.cells:
for para in cell.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
if _has_unmatched(run.text):
_highlight_run(run)
for section in doc.sections:
@@ -374,5 +422,5 @@ def _highlight_unmatched(doc):
if hf:
for para in hf.paragraphs:
for run in para.runs:
if '{{' in run.text and '}}' in run.text:
if _has_unmatched(run.text):
_highlight_run(run)
+1 -6
View File
@@ -173,7 +173,7 @@ def _import_152_is_list(wb, data, log_fn):
is_obj = {
'name': '', 'description': '', 'room': '', 'employee': '', 'position': '',
'software': '', 'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [],
'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [],
'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000',
'defense_tools': [], 'processing_mode': '',
}
@@ -232,8 +232,6 @@ def _import_152_is_list(wb, data, log_fn):
pass
elif 'номер кабинета' in field_lower:
is_obj['room'] = val_b
elif 'названия программ' in field_lower or ('программ' in field_lower and 'перечень' not in field_lower):
is_obj['software'] = val_b
elif 'значение' in field_lower or 'примечание' in field_lower:
continue
elif val_b and 'выбрать' not in field_lower:
@@ -302,9 +300,6 @@ def _import_152_is_list(wb, data, log_fn):
is_obj['name'] = sheet_name.strip()
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
if is_obj['software'] and not is_obj['description']:
is_obj['description'] = f"Используемое ПО: {is_obj['software']}"
# Автоопределение категорий ПДн
pd_cats = []
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
-1
View File
@@ -18,7 +18,6 @@ class InformationSystem:
"""Информационная система (обработка ПДн)"""
name: str = ""
description: str = ""
software: str = ""
is_local_network: bool = False
is_internet: bool = False
defence_level: str = ""
+153 -21
View File
@@ -201,7 +201,7 @@ class DokoGenApp:
addr_fields = [
("Юридический адрес:", "address"),
("Город:", "city_name"),
("Город (заполняется автоматически):", "city_name"),
("ИНН:", "inn"),
("КПП:", "kpp"),
("ОГРН:", "ogrn"),
@@ -212,6 +212,12 @@ class DokoGenApp:
entry = ttk.Entry(f2, width=50)
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries[key] = entry
# Привязка автозаполнения города из адреса
addr_entry = self._company_entries.get('address')
if addr_entry:
addr_entry.bind('<KeyRelease>', self._on_address_changed)
f2.columnconfigure(1, weight=1)
# --- Должностные лица ---
@@ -278,13 +284,9 @@ class DokoGenApp:
self.is_description_entry = ttk.Entry(fields_frame, width=55)
self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
ttk.Label(fields_frame, text="ПО:").grid(row=2, column=0, sticky=tk.W, pady=2)
self.is_software_entry = ttk.Entry(fields_frame, width=55)
self.is_software_entry.grid(row=2, column=1, padx=10, sticky=tk.EW)
# Чекбоксы
chk_frame = ttk.Frame(fields_frame)
chk_frame.grid(row=3, column=1, sticky=tk.W, pady=5)
chk_frame.grid(row=2, column=1, sticky=tk.W, pady=5)
self.is_lan_var = tk.BooleanVar()
ttk.Checkbutton(chk_frame, text="ЛВС", variable=self.is_lan_var).pack(side=tk.LEFT, padx=5)
self.is_internet_var = tk.BooleanVar()
@@ -292,33 +294,42 @@ class DokoGenApp:
# Тип угроз
self.is_threat_var = tk.StringVar(value="3")
ttk.Label(fields_frame, text="Тип угроз:").grid(row=4, column=0, sticky=tk.W, pady=2)
ttk.Label(fields_frame, text="Тип угроз:").grid(row=3, column=0, sticky=tk.W, pady=2)
threat_frame = ttk.Frame(fields_frame)
threat_frame.grid(row=4, column=1, sticky=tk.W, padx=10)
threat_frame.grid(row=3, column=1, sticky=tk.W, padx=10)
for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]:
ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var,
value=val).pack(side=tk.LEFT, padx=3)
# Уровень защищённости
self.is_defence_var = tk.StringVar(value="4")
ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=5, column=0, sticky=tk.W, pady=2)
ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=4, column=0, sticky=tk.W, pady=2)
def_frame = ttk.Frame(fields_frame)
def_frame.grid(row=5, column=1, sticky=tk.W, padx=10)
def_frame.grid(row=4, column=1, sticky=tk.W, padx=10)
for lvl in ["1", "2", "3", "4"]:
ttk.Radiobutton(def_frame, text=f"{lvl} ур.",
variable=self.is_defence_var, value=lvl).pack(side=tk.LEFT, padx=3)
ttk.Button(def_frame, text="Рассчитать автоматически",
command=self._auto_calc_defence_level).pack(side=tk.LEFT, padx=10)
# Категории ПДн
ttk.Label(fields_frame, text="Категории ПДн:").grid(row=6, column=0, sticky=tk.NW, pady=2)
ttk.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2)
cat_frame = ttk.Frame(fields_frame)
cat_frame.grid(row=6, column=1, sticky=tk.W, padx=10)
cat_frame.grid(row=5, column=1, sticky=tk.W, padx=10)
self.category_vars = []
for cat in ["специальные", "биометрические", "общедоступные", "иные"]:
var = tk.BooleanVar()
var.trace_add('write', lambda *_: self._on_categories_or_count_changed())
cb = ttk.Checkbutton(cat_frame, text=cat, variable=var)
cb.pack(anchor=tk.W)
self.category_vars.append((cat, var))
# Количество записей ПДн
ttk.Label(fields_frame, text="Кол-во записей:").grid(row=6, column=0, sticky=tk.W, pady=2)
self.is_pd_count_entry = ttk.Entry(fields_frame, width=30)
self.is_pd_count_entry.grid(row=6, column=1, padx=10, sticky=tk.W)
self.is_pd_count_entry.bind('<KeyRelease>', lambda e: self._on_categories_or_count_changed())
fields_frame.columnconfigure(1, weight=1)
# Вложенные списки
@@ -405,7 +416,17 @@ class DokoGenApp:
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Пользователи")
frame = ttk.Frame(tab, padding=10)
canvas = tk.Canvas(tab, highlightthickness=0)
scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview)
scrollable = ttk.Frame(canvas)
scrollable.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(scrollable, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Сотрудники, имеющие доступ к ПДн",
@@ -572,13 +593,14 @@ class DokoGenApp:
isys = self.model.information_systems[self._current_is_idx]
isys.name = self.is_name_entry.get().strip()
isys.description = self.is_description_entry.get().strip()
isys.software = self.is_software_entry.get().strip()
isys.is_local_network = self.is_lan_var.get()
isys.is_internet = self.is_internet_var.get()
isys.threat_type = self.is_threat_var.get()
isys.defence_level = self.is_defence_var.get()
cats = [c for c, v in self.category_vars if v.get()]
isys.personal_data_category = cats
if hasattr(self, 'is_pd_count_entry'):
isys.pd_count = self.is_pd_count_entry.get().strip() or 'менее 100 000'
# Списки
isys.personal_data_list = list(self.pd_listbox.get(0, tk.END))
isys.pd_subjects_list = list(self.pd_subjects_listbox.get(0, tk.END))
@@ -657,6 +679,92 @@ class DokoGenApp:
# Шаблоны
self._on_templates_changed()
# Автоопределение города из адреса после загрузки
self._on_address_changed()
# ==================== ГОРОД ИЗ АДРЕСА ====================
def _extract_city_from_address(self, address: str) -> str:
"""Извлекает название города из адреса (простая эвристика)."""
if not address:
return ''
# Пробуем найти "г. Город"
import re
m = re.search(r'\bг\.\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
if m:
return m.group(1).strip()
# Пробуем найти "город Город"
m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
if m:
return m.group(1).strip()
# Пробуем первую часть до запятой, если там известный город
parts = [p.strip() for p in address.split(',')]
if parts:
first = parts[0]
# Если первая часть — короткая и не похожа на улицу/дом
if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')):
# Проверяем, что это похоже на город
city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск']
if any(first.lower().endswith(kw) for kw in city_keywords):
return first
return ''
def _on_address_changed(self, event=None):
"""Обработчик изменения адреса — автозаполняет город."""
addr_entry = self._company_entries.get('address')
city_entry = self._company_entries.get('city_name')
if not addr_entry or not city_entry:
return
address = addr_entry.get().strip()
if not address:
return
current_city = city_entry.get().strip()
if current_city:
return # Не перезаписываем, если уже заполнен
city = self._extract_city_from_address(address)
if city:
city_entry.delete(0, tk.END)
city_entry.insert(0, city)
self.log(f"🏙 Город определён: {city}")
# ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ====================
def _auto_calc_defence_level(self):
"""Автоматический расчёт уровня защищённости на основе категорий ПДн и кол-ва записей."""
if self._current_is_idx is None:
return
# Считываем категории
categories = [cat for cat, var in self.category_vars if var.get()]
pd_count_text = self.is_pd_count_entry.get().strip() if hasattr(self, 'is_pd_count_entry') else ''
# Определяем базовый уровень (4 — самый низкий, по умолчанию)
level = 4
# Специальные или биометрические → минимум 3 уровень
has_special = any(c in categories for c in ['специальные', 'биометрические'])
if has_special:
level = 3
# Больше 100 000 записей → повышаем уровень на 1 (уровень становится строже)
if pd_count_text:
# Парсим число
import re
nums = re.findall(r'\d+', pd_count_text.replace(' ', ''))
if nums:
count = int(nums[0])
if count > 100000:
level = max(1, level - 1) # Уровень повышается (число уменьшается)
# Также проверяем текстовые варианты
if 'более' in pd_count_text.lower() or 'свыше' in pd_count_text.lower():
level = max(1, level - 1)
self.is_defence_var.set(str(level))
self.log(f"🛡 Уровень защищённости автоматически рассчитан: {level}")
def _on_categories_or_count_changed(self):
"""Обработчик изменения категорий ПДн или количества записей."""
if self._current_is_idx is not None:
self._auto_calc_defence_level()
# ==================== КОМИССИЯ ====================
def _add_commission_row(self, role="Член комиссии", position="", fio=""):
row = ttk.Frame(self._comm_members_frame)
@@ -665,7 +773,7 @@ class DokoGenApp:
role_var = tk.StringVar(value=role)
role_cb = ttk.Combobox(row, textvariable=role_var, state='readonly', width=22,
values=["Председатель комиссии", "Секретарь комиссии",
"Член комиссии", "Заместитель председателя"])
"Член комиссии"])
role_cb.pack(side=tk.LEFT, padx=2)
pos_entry = ttk.Entry(row, width=30)
@@ -702,8 +810,6 @@ class DokoGenApp:
def _set_is_edit_state(self, enabled):
state = 'normal' if enabled else 'disabled'
for w in [self.is_name_entry, self.is_description_entry, self.is_software_entry]:
w.config(state=state)
self.is_lan_var.set(False)
self.is_internet_var.set(False)
@@ -726,14 +832,16 @@ class DokoGenApp:
self.is_name_entry.insert(0, isys.name or "")
self.is_description_entry.delete(0, tk.END)
self.is_description_entry.insert(0, isys.description or "")
self.is_software_entry.delete(0, tk.END)
self.is_software_entry.insert(0, isys.software or "")
self.is_lan_var.set(isys.is_local_network)
self.is_internet_var.set(isys.is_internet)
self.is_threat_var.set(isys.threat_type or "3")
self.is_defence_var.set(isys.defence_level or "4")
for cat, var in self.category_vars:
var.set(cat in isys.personal_data_category)
# Заполняем кол-во записей ПДн
if hasattr(self, 'is_pd_count_entry'):
self.is_pd_count_entry.delete(0, tk.END)
self.is_pd_count_entry.insert(0, isys.pd_count or 'менее 100 000')
# Списки
for lb, items in [
@@ -765,7 +873,6 @@ class DokoGenApp:
copy = InformationSystem(
name=src.name + " (копия)",
description=src.description,
software=src.software,
is_local_network=src.is_local_network,
is_internet=src.is_internet,
defence_level=src.defence_level,
@@ -978,6 +1085,27 @@ class DokoGenApp:
os.makedirs(output_dir, exist_ok=True)
# Окно прогресса
progress_win = tk.Toplevel(self.root)
progress_win.title("Генерация документов")
progress_win.geometry("350x100")
progress_win.transient(self.root)
progress_win.grab_set()
progress_win.resizable(False, False)
ttk.Label(progress_win, text="Генерация документов...",
font=('Arial', 10)).pack(pady=(15, 5))
pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300)
pb.pack(pady=5)
pb.start()
def _close_progress():
try:
pb.stop()
progress_win.grab_release()
progress_win.destroy()
except Exception:
pass
# Запускаем в отдельном потоке
def _run():
try:
@@ -1006,12 +1134,14 @@ class DokoGenApp:
self.log(f"{fname}: {e}")
self.log(f"✅ Готово: {len(results)} из {len(selected)}")
self.root.after(0, _close_progress)
self.root.after(0, lambda: messagebox.showinfo(
"Готово", f"Сгенерировано {len(results)} документов\n"
f"Папка: {output_dir}"
))
except Exception as e:
self.log(f"❌ Ошибка: {e}")
self.root.after(0, _close_progress)
self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e)))
threading.Thread(target=_run, daemon=True).start()
@@ -1114,6 +1244,9 @@ class DokoGenApp:
entry.delete(0, tk.END)
entry.insert(0, val)
# Автоопределение города из адреса после импорта
self._on_address_changed()
# Должностные лица (маппинг)
officials_map = {
'chief_position': 'chiefPosition',
@@ -1152,7 +1285,6 @@ class DokoGenApp:
isys = InformationSystem(
name=is_data.get('name', ''),
description=is_data.get('description', ''),
software=is_data.get('software', ''),
is_internet=is_data.get('isInternet', False),
pd_count=is_data.get('personalDataCount', 'менее 100 000'),
personal_data_list=is_data.get('personalDataList', []),
-2
View File
@@ -169,7 +169,6 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
'is_users': ', '.join(isys.users_list or []) or '',
'is_pd_count': str(isys.pd_count or ''),
'is_purpose': isys.purpose or '',
'is_software_list': isys.software or '',
}
for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
@@ -198,7 +197,6 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
items.append({
'name': isys.name or '',
'description': isys.description or '',
'software': isys.software or '',
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
'has_internet': 'да' if isys.is_internet else 'нет',
'defence_level': str(isys.defence_level or ''),