rewrite: чистый генератор с ZIP-уровневой обработкой циклов

This commit is contained in:
2026-07-31 00:23:35 +04:00
commit 745e70a164
16 changed files with 3495 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
"""DokoGen — Справочник переменных шаблонов и резолвер значений"""
from datetime import datetime
from typing import Dict, Any, List, Optional
import re
from . import declension as dc
from .models import Company, CommissionMember
# ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================
# Формат: (имя_переменной, группа, описание, ключ_резолвера)
# Группы: A — общие, B — 152-ФЗ
VARIABLE_DEFS = [
# === Полное название организации (6 падежей) ===
('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'),
('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'),
('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'),
('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'),
('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'),
('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'),
# === Краткое название организации (6 падежей) ===
('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'),
('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'),
('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'),
('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'),
('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'),
('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'),
# === Должностные лица ===
('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'),
('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'),
('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'),
('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'),
('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'),
('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'),
('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'),
('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'),
('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'),
# === Сведения об организации ===
('{{company_address}}', 'A', 'Адрес организации', 'company:address'),
('{{city_name}}', 'A', 'Город', 'company:city_name'),
('{{company_inn}}', 'A', 'ИНН', 'company:inn'),
('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'),
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
# === Комиссия ===
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
# === Специальные переменные ===
('{{date_year}}', 'A', 'Текущий год', 'date:year'),
('{{date}}', 'A', 'Текущая дата', 'date:full'),
('{{document_date}}', 'A', 'Дата документа', 'doc_date'),
('{{document_number}}', 'A', 'Номер документа', 'doc_number'),
# === 152-ФЗ ===
('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'),
('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'),
('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'),
('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'),
('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'),
('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'),
('{{number}}', 'B', 'Номер (алиас)', 'doc_number'),
('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'),
('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'),
('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'),
('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'),
('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'),
('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'),
('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'),
('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'),
('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'),
('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'),
('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'),
('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'),
('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'),
('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'),
('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'),
('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'),
]
# Маппинг старых переменных <...> → {{...}}
OLD_VAR_MAP = {
'<CompanyName1>': '{{company_full_name}}',
'<CompanyName2>': '{{company_short_name}}',
'<CompanyName1Gent>': '{{company_full_name_genitive}}',
'<CompanyName2Gent>': '{{company_short_name_genitive}}',
'<CompanyName1Datv>': '{{company_full_name_dative}}',
'<CompanyName2Datv>': '{{company_short_name_dative}}',
'<CompanyName1Accs>': '{{company_full_name_accs}}',
'<CompanyName2Accs>': '{{company_short_name_accs}}',
'<CompanyName1Ablt>': '{{company_full_name_ablt}}',
'<CompanyName2Ablt>': '{{company_short_name_ablt}}',
'<CompanyName1Loct>': '{{company_full_name_loct}}',
'<CompanyName2Loct>': '{{company_short_name_loct}}',
'<Address>': '{{company_address}}',
'<INN>': '{{company_inn}}',
'<OGRN>': '{{company_ogrn}}',
'<KPP>': '{{company_kpp}}',
'<ChiefFio>': '{{chief_fio_initials}}',
'<ChiefFioShort>': '{{chief_fio_short}}',
'<ChiefPosition>': '{{chief_position}}',
'<RspFio>': '{{responsible_fio_initials}}',
'<RspFioShort>': '{{responsible_fio_short}}',
'<RspPosition>': '{{responsible_position}}',
'<CommissionList>': '{{commission_list}}',
'<DocumentNumber>': '{{document_number}}',
'<DocumentDate>': '{{document_date}}',
'<Date>': '{{date}}',
'<SurveyDate>': '{{document_date}}',
}
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
# ============================================================
# ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН
# ============================================================
def build_replacements(
company: Company,
is_list: List = None,
doc_number: str = "",
doc_date: str = "",
direction: str = "152fz",
) -> Dict[str, str]:
"""Строит полный словарь замен {{переменная}} → значение."""
if is_list is None:
is_list = []
# ---------- Комиссия ----------
commission_data = _build_commission_data(company)
# ---------- Контекст ----------
ctx = {
'company': company,
'is_list': is_list,
'commission_data': commission_data,
'doc_number': doc_number or '',
'doc_date': doc_date or '',
'direction': direction,
}
replacements = {}
for var_name, group, desc, source in VARIABLE_DEFS:
if direction == '152fz' and group not in ('A', 'B'):
continue
value = _resolve(source, ctx)
replacements[var_name] = str(value) if value else ''
# Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.)
for i, isys in enumerate(is_list, 1):
_add_is_indexed_vars(replacements, isys, i)
# Циклы
replacements['_loops'] = _build_loop_data(company, is_list)
return replacements
def _build_commission_data(company: Company) -> Dict[str, str]:
data = {}
members = company.commission or []
lines = []
for i, m in enumerate(members, 1):
role = m.role or 'член комиссии'
line = f"{i}. {role}: {m.position}{m.fio}" if m.position else f"{i}. {role}: {m.fio}"
lines.append(line)
data['commission_list'] = '\n'.join(lines) if lines else ''
return data
def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
fields = {
'is_name': isys.name or '',
'is_address': getattr(isys, 'address', '') or '',
'is_description': isys.description or '',
'is_has_internet': 'да' if isys.is_internet else 'нет',
'is_defence_level': str(isys.defence_level or ''),
'is_category': isys.category or '',
'is_pd_list': ', '.join(isys.personal_data_list or []) or '',
'is_users': ', '.join(isys.users_list or []) or '',
'is_pd_count': str(isys.pd_count or ''),
'is_purpose': isys.purpose or '',
}
for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
loops = {}
# Комиссия
members = company.commission or []
if members:
items = []
for m in members:
items.append({
'role': m.role or 'член комиссии',
'position': m.position or '',
'fio': m.fio or '',
'full': f"{m.role} {m.position}{m.fio}" if m.position else f"{m.role}{m.fio}",
})
loops['commission'] = items
# ИС
if is_list:
items = []
for isys in is_list:
items.append({
'name': isys.name or '',
'description': isys.description or '',
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
'has_internet': 'да' if isys.is_internet else 'нет',
'defence_level': str(isys.defence_level or ''),
'threat_type': str(isys.threat_type or '3'),
'category': isys.category or '',
'pd_list': ', '.join(isys.personal_data_list or []) or '',
'pd_count': str(isys.pd_count or ''),
'users': ', '.join(isys.users_list or []) or '',
'defense_tools_list': '; '.join(isys.defense_tools_list or []) or '',
'processing_modes': isys.processing_modes or '',
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '',
'is_purpose': isys.purpose or '',
})
loops['information_systems'] = items
# Бумажные документы
paper_list = getattr(company, 'paper_documents_list', None) or []
if paper_list:
items = []
for p in paper_list:
items.append({
'document': p.name or '',
'storage': p.storage or '',
})
loops['paper_documents'] = items
# Сотрудники с доступом
employees = getattr(company, 'employees_access', None) or []
if employees:
items = []
for e in employees:
items.append({
'position': e.get('position', ''),
'fio': e.get('fio', ''),
})
loops['employees_access'] = items
return loops
# ============================================================
# РЕЗОЛВЕР ЗНАЧЕНИЙ
# ============================================================
def _resolve(source: str, ctx: Dict) -> str:
"""Преобразует ключ-источник в значение, подставляя из контекста."""
company = ctx.get('company')
is_list = ctx.get('is_list', [])
commission_data = ctx.get('commission_data', {})
doc_number = ctx.get('doc_number', '')
doc_date = ctx.get('doc_date', '')
# Прямые поля Company
if source.startswith('company:'):
field = source[8:]
return str(getattr(company, field, '') or '')
# Склонение названия
if source.startswith('decl:'):
_, field, case = source.split(':')
name = getattr(company, field, '') or ''
return dc.company_name_decline(name, case) if name else ''
# Форматы ФИО
if source.startswith('short_fio:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_short_fio(fio) if fio else ''
if source.startswith('initials:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_initials(fio) if fio else ''
# Комиссия
if source in commission_data:
return str(commission_data[source])
# Даты
if source.startswith('date:'):
fmt = source.split(':', 1)[1]
now = datetime.now()
if fmt == 'year':
return str(now.year)
return now.strftime('%d.%m.%Y')
if source == 'doc_date':
return doc_date or datetime.now().strftime('%d.%m.%Y')
if source == 'doc_number':
num = str(doc_number).strip()
return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or ''
# Требования защищённости
if source == 'security_reqs':
return _get_security_text(ctx)
# IS-алиасы (первая ИС)
if source.startswith('is:'):
field = source[3:]
if is_list:
first = is_list[0]
field_map = {
'name': 'name',
'description': 'description',
'category': 'category',
'defence_level': 'defence_level',
'threat_type': 'threat_type',
'pd_list': lambda: ', '.join(first.personal_data_list or []),
'pd_count': 'pd_count',
'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []),
'users': lambda: ', '.join(first.users_list or []),
}
if field in field_map:
val = field_map[field]
if callable(val):
return val() or ''
return str(getattr(first, val, '') or '') if isinstance(val, str) else ''
return ''
# Список ИС
if source == 'is_list':
if is_list:
return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1))
return ''
if source == 'is_list_comma':
if is_list:
return ', '.join(isys.name for isys in is_list)
return ''
# Члены комиссии
if source.startswith('commission_member:'):
parts = source.split(':')
idx, field = int(parts[1]), parts[2]
members = getattr(company, 'commission', None) or []
if idx < len(members):
return str(getattr(members[idx], field, '') or '')
return ''
# Склонение ФИО по падежам
if source.startswith('fio_acl:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.decline(fio, 'accs') if fio else ''
return ''
def _get_fio(company: Company, who: str) -> str:
mapping = {
'chief': 'chief_fio',
'responsible': 'responsible_fio',
'admin': 'admin_fio',
}
field = mapping.get(who, who + '_fio')
return getattr(company, field, '') or ''
def _get_security_text(ctx: Dict) -> str:
"""Возвращает текст требований для уровня защищённости."""
defence_level = ctx.get('extended_fields', {}).get('defence_level', '4')
is_list = ctx.get('is_list', [])
if not defence_level and is_list:
defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4')
dl = str(defence_level).strip()[0]
TEXTS = {
'1': """Для обеспечения 1-го уровня защищённости...""",
'2': """Для обеспечения 2-го уровня защищённости...""",
'3': """Для обеспечения 3-го уровня защищённости...""",
'4': """Для обеспечения 4-го уровня защищённости персональных данных необходимо:
— режим безопасности помещений;
— сохранность носителей ПДн;
— утверждение перечня лиц с доступом к ПДн;
— использование сертифицированных СЗИ.""",
}
return TEXTS.get(dl, TEXTS['4'])