mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-15 22:35:23 +03:00
2341 lines
116 KiB
Python
2341 lines
116 KiB
Python
#!/usr/bin/env python3
|
||
# role-edit v1.7 — Edit existing 1C role rights in place
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
|
||
from lxml import etree
|
||
|
||
# регистр не различают, в argparse совпадение точное.
|
||
|
||
def parse_json_input(text, source, expected=None, inline=False):
|
||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||
|
||
expected заполняем только для полиморфного входа: у файла подсказка
|
||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||
|
||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||
"""
|
||
import json as _pj
|
||
import sys as _psys
|
||
try:
|
||
if not str(text).strip():
|
||
raise ValueError("input is empty")
|
||
return _pj.loads(text)
|
||
except ValueError as exc:
|
||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||
if inline:
|
||
got = " ".join(str(text).split())
|
||
label = "got"
|
||
if not got:
|
||
got = "(empty)"
|
||
elif len(got) > 60:
|
||
label = "got (first 60 chars)"
|
||
got = got[:60]
|
||
what = "%s, %s: %s" % (what, label, got)
|
||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||
_psys.exit(1)
|
||
|
||
|
||
def read_json_file(path):
|
||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||
|
||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||
"""
|
||
import os as _pos
|
||
import sys as _psys
|
||
if not _pos.path.exists(path):
|
||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||
_psys.exit(1)
|
||
if _pos.path.isdir(path):
|
||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||
_psys.exit(1)
|
||
with open(path, "rb") as _fh:
|
||
data = _fh.read()
|
||
if data[:3] == b"\xef\xbb\xbf":
|
||
return data[3:].decode("utf-8")
|
||
if data[:2] == b"\xff\xfe":
|
||
return data[2:].decode("utf-16-le")
|
||
if data[:2] == b"\xfe\xff":
|
||
return data[2:].decode("utf-16-be")
|
||
try:
|
||
return data.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||
% (path, exc), file=_psys.stderr)
|
||
_psys.exit(1)
|
||
|
||
|
||
class CIDict(dict):
|
||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||
# сохраняется — от него зависит порядок эмиссии.
|
||
def _actual(self, key):
|
||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||
return key
|
||
ci = self.__dict__.get('_ci')
|
||
if ci is None or len(ci) != len(self):
|
||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||
self.__dict__['_ci'] = ci
|
||
return ci.get(key.lower(), key)
|
||
|
||
def __getitem__(self, key):
|
||
return dict.__getitem__(self, self._actual(key))
|
||
|
||
def __contains__(self, key):
|
||
return dict.__contains__(self, self._actual(key))
|
||
|
||
def get(self, key, default=None):
|
||
return dict.get(self, self._actual(key), default)
|
||
|
||
def pop(self, key, *default):
|
||
return dict.pop(self, self._actual(key), *default)
|
||
|
||
def __setitem__(self, key, value):
|
||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||
dict.__setitem__(self, self._actual(key), value)
|
||
|
||
def ci_json(obj):
|
||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||
if isinstance(obj, dict):
|
||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||
if isinstance(obj, list):
|
||
return [ci_json(v) for v in obj]
|
||
return obj
|
||
|
||
def ci_parse_args(parser, argv=None):
|
||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||
argv = list(sys.argv[1:] if argv is None else argv)
|
||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||
for i, tok in enumerate(argv):
|
||
if tok.startswith('-') and tok.lower() in names:
|
||
argv[i] = names[tok.lower()]
|
||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||
choice_map = {}
|
||
for a in parser._actions:
|
||
if a.choices:
|
||
for s in a.option_strings:
|
||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||
for i in range(len(argv) - 1):
|
||
m = choice_map.get(argv[i])
|
||
if m and argv[i + 1].lower() in m:
|
||
argv[i + 1] = m[argv[i + 1].lower()]
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
|
||
# ============================================================
|
||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||
# ============================================================
|
||
|
||
def _sg_root_uuid(xml_path):
|
||
if not os.path.isfile(xml_path):
|
||
return None
|
||
try:
|
||
mx = etree.parse(xml_path).getroot()
|
||
for child in mx:
|
||
if isinstance(child.tag, str) and child.get("uuid"):
|
||
return child.get("uuid")
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _sg_is_external_root(xml_path):
|
||
if not os.path.isfile(xml_path):
|
||
return False
|
||
try:
|
||
mx = etree.parse(xml_path).getroot()
|
||
for child in mx:
|
||
if isinstance(child.tag, str):
|
||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def _sg_find_v8project(start_dir):
|
||
d = start_dir
|
||
for _ in range(20):
|
||
if not d:
|
||
break
|
||
pj = os.path.join(d, ".v8-project.json")
|
||
if os.path.isfile(pj):
|
||
return pj
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
break
|
||
d = parent
|
||
return None
|
||
|
||
|
||
def _sg_get_edit_mode(cfg_dir):
|
||
try:
|
||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||
if not pj:
|
||
return "deny"
|
||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||
for db in proj.get("databases", []):
|
||
src = db.get("configSrc")
|
||
if src:
|
||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||
if db.get("editingAllowedCheck"):
|
||
return db["editingAllowedCheck"]
|
||
if proj.get("editingAllowedCheck"):
|
||
return proj["editingAllowedCheck"]
|
||
return "deny"
|
||
except Exception:
|
||
return "deny"
|
||
|
||
|
||
def assert_edit_allowed(target_path, require):
|
||
try:
|
||
rp = os.path.abspath(target_path)
|
||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||
if _sg_is_external_root(rp):
|
||
return
|
||
elem_uuid = _sg_root_uuid(rp)
|
||
cfg_dir = None
|
||
bin_path = None
|
||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||
for _ in range(12):
|
||
if not d:
|
||
break
|
||
if _sg_is_external_root(d + ".xml"):
|
||
return
|
||
if not elem_uuid:
|
||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||
if not cfg_dir:
|
||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||
cfg_dir = d
|
||
bin_path = cand
|
||
if elem_uuid and cfg_dir:
|
||
break
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
break
|
||
d = parent
|
||
if not elem_uuid and cfg_dir:
|
||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||
if not bin_path or not os.path.exists(bin_path):
|
||
return
|
||
data = open(bin_path, "rb").read()
|
||
if len(data) <= 32:
|
||
return
|
||
if data[:3] == b"\xef\xbb\xbf":
|
||
data = data[3:]
|
||
text = data.decode("utf-8", "replace")
|
||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||
if not h:
|
||
return
|
||
g = int(h.group(1))
|
||
k = int(h.group(2))
|
||
if k == 0:
|
||
return
|
||
best = None
|
||
if elem_uuid:
|
||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||
f1 = int(m.group(1))
|
||
if best is None or f1 < best:
|
||
best = f1
|
||
blocked = False
|
||
code = ""
|
||
reason = ""
|
||
if g == 1:
|
||
blocked = True
|
||
code = "capability-off"
|
||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||
elif require == "removed":
|
||
if best is not None and best != 2:
|
||
blocked = True
|
||
code = "not-removed"
|
||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||
else:
|
||
if best is not None and best == 0:
|
||
blocked = True
|
||
code = "locked"
|
||
reason = "объект на замке — редактирование сломает обновления"
|
||
if not blocked:
|
||
return
|
||
mode = _sg_get_edit_mode(cfg_dir)
|
||
if mode == "off":
|
||
return
|
||
if mode == "warn":
|
||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||
return
|
||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||
if code == "capability-off":
|
||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||
fix = (
|
||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||
)
|
||
elif code == "not-removed":
|
||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||
fix = (
|
||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||
)
|
||
else:
|
||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||
fix = (
|
||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||
)
|
||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||
sys.exit(1)
|
||
except SystemExit:
|
||
raise
|
||
except Exception:
|
||
return
|
||
|
||
|
||
|
||
def detect_format_version(d):
|
||
while d:
|
||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||
ext_path = d + ".xml"
|
||
if os.path.isfile(ext_path):
|
||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||
ext_head = f.read(2000)
|
||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||
if m:
|
||
return m.group(1)
|
||
cfg_path = os.path.join(d, "Configuration.xml")
|
||
if os.path.isfile(cfg_path):
|
||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||
head = f.read(2000)
|
||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||
if m:
|
||
return m.group(1)
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
break
|
||
d = parent
|
||
return "2.17"
|
||
|
||
|
||
def format_rank(ver):
|
||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||
|
||
# --- Russian synonyms -> canonical English names ---
|
||
|
||
TYPE_ALIASES = {
|
||
"Справочник": "Catalog",
|
||
"Документ": "Document",
|
||
"РегистрСведений": "InformationRegister",
|
||
"РегистрНакопления": "AccumulationRegister",
|
||
"РегистрБухгалтерии": "AccountingRegister",
|
||
"РегистрРасчета": "CalculationRegister",
|
||
"РегистрРасчёта": "CalculationRegister",
|
||
"Константа": "Constant",
|
||
"ПланСчетов": "ChartOfAccounts",
|
||
"ПланВидовХарактеристик": "ChartOfCharacteristicTypes",
|
||
"ПланВидовРасчета": "ChartOfCalculationTypes",
|
||
"ПланВидовРасчёта": "ChartOfCalculationTypes",
|
||
"ПланОбмена": "ExchangePlan",
|
||
"БизнесПроцесс": "BusinessProcess",
|
||
"Задача": "Task",
|
||
"Обработка": "DataProcessor",
|
||
"Отчет": "Report",
|
||
"Отчёт": "Report",
|
||
"ОбщаяФорма": "CommonForm",
|
||
"ОбщаяКоманда": "CommonCommand",
|
||
"Подсистема": "Subsystem",
|
||
"КритерийОтбора": "FilterCriterion",
|
||
"ЖурналДокументов": "DocumentJournal",
|
||
"Последовательность": "Sequence",
|
||
"ВебСервис": "WebService",
|
||
"HTTPСервис": "HTTPService",
|
||
"СервисИнтеграции": "IntegrationService",
|
||
"ПараметрСеанса": "SessionParameter",
|
||
"ОбщийРеквизит": "CommonAttribute",
|
||
"Конфигурация": "Configuration",
|
||
"ВнешнийИсточникДанных": "ExternalDataSource",
|
||
# Типы без прав в ролях: алиасы нужны не ради генерации, а ради отказа по делу —
|
||
# иначе на русскую запись навык ответит «неизвестный тип 'ОбщийМодуль'».
|
||
"Перечисление": "Enum",
|
||
"ОбщийМодуль": "CommonModule",
|
||
"ОпределяемыйТип": "DefinedType",
|
||
"ОбщаяКартинка": "CommonPicture",
|
||
"ОбщийМакет": "CommonTemplate",
|
||
"Язык": "Language",
|
||
"ФункциональнаяОпция": "FunctionalOption",
|
||
"ПараметрФункциональныхОпций": "FunctionalOptionsParameter",
|
||
"ПодпискаНаСобытие": "EventSubscription",
|
||
"РегламентноеЗадание": "ScheduledJob",
|
||
"ЭлементСтиля": "StyleItem",
|
||
"ХранилищеНастроек": "SettingsStorage",
|
||
"ПакетXDTO": "XDTOPackage",
|
||
"WSСсылка": "WSReference",
|
||
"Нумератор": "DocumentNumerator",
|
||
# Nested
|
||
"Реквизит": "Attribute",
|
||
"СтандартныйРеквизит": "StandardAttribute",
|
||
"ТабличнаяЧасть": "TabularSection",
|
||
"Измерение": "Dimension",
|
||
"Ресурс": "Resource",
|
||
"Команда": "Command",
|
||
"РеквизитАдресации": "AddressingAttribute",
|
||
}
|
||
|
||
RIGHT_ALIASES = {
|
||
"Чтение": "Read",
|
||
"Добавление": "Insert",
|
||
"Изменение": "Update",
|
||
"Удаление": "Delete",
|
||
"Просмотр": "View",
|
||
"Редактирование": "Edit",
|
||
"ВводПоСтроке": "InputByString",
|
||
"Проведение": "Posting",
|
||
"ОтменаПроведения": "UndoPosting",
|
||
"ИнтерактивноеДобавление": "InteractiveInsert",
|
||
"ИнтерактивнаяПометкаУдаления": "InteractiveSetDeletionMark",
|
||
"ИнтерактивноеСнятиеПометкиУдаления": "InteractiveClearDeletionMark",
|
||
"ИнтерактивноеУдаление": "InteractiveDelete",
|
||
"ИнтерактивноеУдалениеПомеченных": "InteractiveDeleteMarked",
|
||
"ИнтерактивноеПроведение": "InteractivePosting",
|
||
"ИнтерактивноеПроведениеНеоперативное": "InteractivePostingRegular",
|
||
"ИнтерактивнаяОтменаПроведения": "InteractiveUndoPosting",
|
||
"ИнтерактивноеИзменениеПроведенных": "InteractiveChangeOfPosted",
|
||
"Использование": "Use",
|
||
"Получение": "Get",
|
||
"Установка": "Set",
|
||
"Старт": "Start",
|
||
"ИнтерактивныйСтарт": "InteractiveStart",
|
||
"ИнтерактивнаяАктивация": "InteractiveActivate",
|
||
"Выполнение": "Execute",
|
||
"ИнтерактивноеВыполнение": "InteractiveExecute",
|
||
"УправлениеИтогами": "TotalsControl",
|
||
"Администрирование": "Administration",
|
||
"АдминистрированиеДанных": "DataAdministration",
|
||
"ТонкийКлиент": "ThinClient",
|
||
"ВебКлиент": "WebClient",
|
||
"ТолстыйКлиент": "ThickClient",
|
||
"ВнешнееСоединение": "ExternalConnection",
|
||
"Вывод": "Output",
|
||
"СохранениеДанныхПользователя": "SaveUserData",
|
||
"МобильныйКлиент": "MobileClient",
|
||
}
|
||
|
||
# --- Known rights per object type ---
|
||
|
||
KNOWN_RIGHTS = {
|
||
"Configuration": [
|
||
"Administration", "DataAdministration", "UpdateDataBaseConfiguration",
|
||
"ConfigurationExtensionsAdministration", "ActiveUsers", "EventLog", "ExclusiveMode",
|
||
"ThinClient", "ThickClient", "WebClient", "MobileClient", "ExternalConnection",
|
||
"Automation", "Output", "SaveUserData", "TechnicalSpecialistMode",
|
||
"InteractiveOpenExtDataProcessors", "InteractiveOpenExtReports",
|
||
"AnalyticsSystemClient", "CollaborationSystemInfoBaseRegistration",
|
||
"MainWindowModeNormal", "MainWindowModeWorkplace",
|
||
"MainWindowModeEmbeddedWorkplace", "MainWindowModeFullscreenWorkplace", "MainWindowModeKiosk",
|
||
],
|
||
"Catalog": [
|
||
"Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark",
|
||
"InteractiveDelete", "InteractiveDeleteMarked",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData",
|
||
"InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"UpdateDataHistoryOfMissingData", "ReadDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"Document": [
|
||
"Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString",
|
||
"Posting", "UndoPosting",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark",
|
||
"InteractiveDelete", "InteractiveDeleteMarked",
|
||
"InteractivePosting", "InteractivePostingRegular", "InteractiveUndoPosting",
|
||
"InteractiveChangeOfPosted",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"UpdateDataHistoryOfMissingData", "ReadDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"InformationRegister": [
|
||
"Read", "Update", "View", "Edit", "TotalsControl",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"UpdateDataHistoryOfMissingData", "ReadDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"AccumulationRegister": ["Read", "Update", "View", "Edit", "TotalsControl"],
|
||
"AccountingRegister": ["Read", "Update", "View", "Edit", "TotalsControl"],
|
||
"CalculationRegister": [
|
||
"Read", "Update", "View", "Edit",
|
||
],
|
||
"Constant": [
|
||
"Read", "Update", "View", "Edit",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfAccounts": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "Edit", "InputByString", "InteractiveInsert",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDelete", "InteractiveDeleteMarked",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfCharacteristicTypes": [
|
||
"Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark",
|
||
"InteractiveDelete", "InteractiveDeleteMarked",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData",
|
||
"InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfCalculationTypes": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "Edit", "InputByString", "InteractiveInsert",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDelete", "InteractiveDeleteMarked",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"ExchangePlan": [
|
||
"Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark",
|
||
"InteractiveDelete", "InteractiveDeleteMarked",
|
||
"ReadDataHistory", "ViewDataHistory", "UpdateDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"BusinessProcess": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "Edit", "InputByString", "Start",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDelete",
|
||
"InteractiveDeleteMarked", "InteractiveActivate", "InteractiveStart", "ReadDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings",
|
||
"UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"Task": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "Edit", "InputByString", "Execute",
|
||
"InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDelete",
|
||
"InteractiveDeleteMarked", "InteractiveActivate", "InteractiveExecute", "ReadDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings",
|
||
"UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"DataProcessor": ["Use", "View"],
|
||
"Report": ["Use", "View"],
|
||
"CommonForm": ["View"],
|
||
"CommonCommand": ["View"],
|
||
"Subsystem": ["View"],
|
||
"FilterCriterion": ["View"],
|
||
"DocumentJournal": ["Read", "View"],
|
||
"Sequence": ["Read", "Update"],
|
||
"WebService": ["Use"],
|
||
"HTTPService": ["Use"],
|
||
"IntegrationService": ["Use"],
|
||
"SessionParameter": ["Get", "Set"],
|
||
"CommonAttribute": ["View", "Edit"],
|
||
"ExternalDataSource": [
|
||
"Use", "Administration", "StandardAuthenticationChange",
|
||
"SessionStandardAuthenticationChange", "SessionOSAuthenticationChange",
|
||
],
|
||
}
|
||
|
||
# Виды вложенности (предпоследний сегмент пути) → допустимые права. Списки сняты с корпуса
|
||
# типовых конфигураций и с выгрузки роли, где права проставлены по всему дереву редактора:
|
||
# догадкам тут не место — закрытый список превращает промах в ложный отказ.
|
||
NESTED_KIND_RIGHTS = {
|
||
"Attribute": ["View", "Edit"],
|
||
"StandardAttribute": ["View", "Edit"],
|
||
"TabularSection": ["View", "Edit"],
|
||
"StandardTabularSection": ["View", "Edit"],
|
||
"Dimension": ["View", "Edit"],
|
||
"Resource": ["View", "Edit"],
|
||
"AccountingFlag": ["View", "Edit"],
|
||
"ExtDimensionAccountingFlag": ["View", "Edit"],
|
||
"AddressingAttribute": ["View", "Edit"],
|
||
"Field": ["View", "Edit"],
|
||
"Command": ["View"],
|
||
"Subsystem": ["View"],
|
||
"Operation": ["Use"],
|
||
"Method": ["Use"],
|
||
"IntegrationServiceChannel": ["Use"],
|
||
"Recalculation": ["Read", "Update"],
|
||
"Cube": ["Read", "View"],
|
||
"DimensionTable": ["Read", "View"],
|
||
"Function": ["Use", "View"],
|
||
"Table": [
|
||
"Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString",
|
||
"InteractiveInsert", "InteractiveDelete",
|
||
],
|
||
}
|
||
|
||
# Виды, существующие только у одного типа-родителя: без этой привязки
|
||
# `Catalog.Товары.Field.Цена` прошёл бы как валидный вложенный объект.
|
||
KIND_OWNERS = {
|
||
'Table': 'ExternalDataSource',
|
||
'Cube': 'ExternalDataSource',
|
||
'Function': 'ExternalDataSource',
|
||
'Field': 'ExternalDataSource',
|
||
'DimensionTable': 'ExternalDataSource',
|
||
'Recalculation': 'CalculationRegister',
|
||
'Operation': 'WebService',
|
||
'Method': 'HTTPService',
|
||
'IntegrationServiceChannel': 'IntegrationService',
|
||
}
|
||
|
||
# Право на сервис живёт на ЛИСТЕ — методе шаблона URL, операции, канале, — а не на самом
|
||
# сервисе: корневого узла нет ни в одной типовой роли (907 записей корпуса — ноль), в
|
||
# Конфигураторе галки на корне нет вовсе. Короткая запись `HTTPService.X: Use` выражает
|
||
# намерение «открой сервис целиком» и раскрывается в листья по метаданным сервиса.
|
||
SERVICE_LEAVES = {
|
||
'WebService': {'dir': 'WebServices', 'kinds': ['Operation']},
|
||
'HTTPService': {'dir': 'HTTPServices', 'kinds': ['URLTemplate', 'Method']},
|
||
'IntegrationService': {'dir': 'IntegrationServices', 'kinds': ['IntegrationServiceChannel']},
|
||
}
|
||
|
||
# Один и тот же вид под разными родителями имеет разный набор: измерение регистра —
|
||
# View + Edit, измерение куба внешнего источника — только View. Объединять нельзя,
|
||
# объединение молча разрешило бы Edit там, где платформа его не даёт.
|
||
NESTED_KIND_RIGHTS_BY_TYPE = {
|
||
"ExternalDataSource": {
|
||
"Dimension": ["View"],
|
||
"Resource": ["View"],
|
||
},
|
||
}
|
||
|
||
# Типы без прав в ролях (в дереве редактора ролей их нет). Список НЕ управляет поведением —
|
||
# отказ даёт отсутствие типа в KNOWN_RIGHTS; здесь только выбор формулировки.
|
||
NO_RIGHTS_TYPES = [
|
||
"Enum", "CommonModule", "DefinedType", "CommonPicture", "CommonTemplate", "Language",
|
||
"FunctionalOption", "FunctionalOptionsParameter", "EventSubscription", "ScheduledJob",
|
||
"StyleItem", "Style", "SettingsStorage", "XDTOPackage", "WSReference", "DocumentNumerator",
|
||
]
|
||
|
||
# --- Presets ---
|
||
|
||
PRESETS = {
|
||
"view": {
|
||
"Catalog": ["Read", "View", "InputByString"],
|
||
"ExchangePlan": ["Read", "View", "InputByString"],
|
||
"Document": ["Read", "View", "InputByString"],
|
||
"ChartOfAccounts": ["Read", "View", "InputByString"],
|
||
"ChartOfCharacteristicTypes": ["Read", "View", "InputByString"],
|
||
"ChartOfCalculationTypes": ["Read", "View", "InputByString"],
|
||
"BusinessProcess": ["Read", "View", "InputByString"],
|
||
"Task": ["Read", "View", "InputByString"],
|
||
"InformationRegister": ["Read", "View"],
|
||
"AccumulationRegister": ["Read", "View"],
|
||
"AccountingRegister": ["Read", "View"],
|
||
"CalculationRegister": ["Read", "View"],
|
||
"Constant": ["Read", "View"],
|
||
"DocumentJournal": ["Read", "View"],
|
||
"Sequence": ["Read"],
|
||
"CommonForm": ["View"],
|
||
"CommonCommand": ["View"],
|
||
"Subsystem": ["View"],
|
||
"FilterCriterion": ["View"],
|
||
"SessionParameter": ["Get"],
|
||
"CommonAttribute": ["View"],
|
||
"DataProcessor": ["Use", "View"],
|
||
"Report": ["Use", "View"],
|
||
"Configuration": ["ThinClient", "WebClient", "Output", "SaveUserData", "MainWindowModeNormal"],
|
||
},
|
||
"edit": {
|
||
"Catalog": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark"],
|
||
"ExchangePlan": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark"],
|
||
"Document": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "Posting", "UndoPosting", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractivePosting", "InteractivePostingRegular", "InteractiveUndoPosting", "InteractiveChangeOfPosted"],
|
||
"ChartOfAccounts": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark"],
|
||
"ChartOfCharacteristicTypes": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark"],
|
||
"ChartOfCalculationTypes": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark"],
|
||
"BusinessProcess": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "Start", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveActivate", "InteractiveStart"],
|
||
"Task": ["Read", "Insert", "Update", "Delete", "View", "Edit", "InputByString", "Execute", "InteractiveInsert", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveActivate", "InteractiveExecute"],
|
||
"InformationRegister": ["Read", "Update", "View", "Edit"],
|
||
"AccumulationRegister": ["Read", "Update", "View", "Edit"],
|
||
"AccountingRegister": ["Read", "Update", "View", "Edit"],
|
||
"Constant": ["Read", "Update", "View", "Edit"],
|
||
"DocumentJournal": ["Read", "View"],
|
||
"Sequence": ["Read", "Update"],
|
||
"SessionParameter": ["Get", "Set"],
|
||
"CommonAttribute": ["View", "Edit"],
|
||
},
|
||
}
|
||
|
||
|
||
def translate_object_name(name):
|
||
parts = name.split('.')
|
||
result = []
|
||
for p in parts:
|
||
result.append(TYPE_ALIASES.get(p, p))
|
||
return '.'.join(result)
|
||
|
||
|
||
def translate_right_name(name):
|
||
return RIGHT_ALIASES.get(name, name)
|
||
|
||
|
||
def get_object_type(object_name):
|
||
dot_idx = object_name.find('.')
|
||
if dot_idx < 0:
|
||
return object_name
|
||
return object_name[:dot_idx]
|
||
|
||
|
||
def is_nested_object(object_name):
|
||
return len(object_name.split('.')) >= 3
|
||
|
||
|
||
def get_nested_kind(object_name):
|
||
"""Вид вложенности — предпоследний сегмент: путь бывает и восьмисегментным
|
||
(ExternalDataSource.И.Cube.К.DimensionTable.Т.Field.П), считать от конца."""
|
||
parts = object_name.split('.')
|
||
if len(parts) < 3:
|
||
return None
|
||
return parts[-2]
|
||
|
||
|
||
def get_nested_rights(object_type, kind):
|
||
by_type = NESTED_KIND_RIGHTS_BY_TYPE.get(object_type)
|
||
if by_type and kind in by_type:
|
||
return by_type[kind]
|
||
return NESTED_KIND_RIGHTS.get(kind)
|
||
|
||
|
||
# --- Зависимости прав (замерено на платформе) ---
|
||
# Платформа при загрузке сама доводит набор до замыкания: выдал Edit — получил ещё
|
||
# Read, Update и View. Пишем замыкание сразу, иначе файл и база расходятся.
|
||
# Таблица общая для типов; исключения — там, где у типа своя механика (обработка и отчёт
|
||
# держатся на Use, план счетов не тянет Read под историю данных).
|
||
RIGHT_DEPS = {
|
||
"Delete": ["Read"],
|
||
"Edit": ["Read", "Update", "View"],
|
||
"EditDataHistoryVersionComment": ["Read", "ReadDataHistory", "UpdateDataHistoryVersionComment", "View"],
|
||
"Execute": ["Read", "Update"],
|
||
"InputByString": ["Read", "View"],
|
||
"Insert": ["Read"],
|
||
"InteractiveActivate": ["Read", "Update"],
|
||
"InteractiveChangeOfPosted": ["Edit", "Read", "Update", "View"],
|
||
"InteractiveClearDeletionMark": ["Edit", "Read", "Update", "View"],
|
||
"InteractiveClearDeletionMarkPredefinedData": ["Edit", "InteractiveClearDeletionMark", "Read", "Update", "View"],
|
||
"InteractiveDelete": ["Delete", "Edit", "Read", "Update", "View"],
|
||
"InteractiveDeleteMarked": ["Delete", "Edit", "Read", "Update", "View"],
|
||
"InteractiveDeleteMarkedPredefinedData": ["Delete", "Edit", "InteractiveDeleteMarked", "Read", "Update", "View"],
|
||
"InteractiveDeletePredefinedData": ["Delete", "Edit", "InteractiveDelete", "Read", "Update", "View"],
|
||
"InteractiveExecute": ["Execute", "Read", "Update"],
|
||
"InteractiveInsert": ["Edit", "Insert", "Read", "Update", "View"],
|
||
"InteractivePosting": ["Edit", "Posting", "Read", "Update", "View"],
|
||
"InteractivePostingRegular": ["Edit", "InteractivePosting", "Posting", "Read", "Update", "View"],
|
||
"InteractiveSetDeletionMark": ["Edit", "Read", "Update", "View"],
|
||
"InteractiveSetDeletionMarkPredefinedData": ["Edit", "InteractiveSetDeletionMark", "Read", "Update", "View"],
|
||
"InteractiveStart": ["Read", "Start", "Update"],
|
||
"InteractiveUndoPosting": ["Edit", "Read", "UndoPosting", "Update", "View"],
|
||
"Posting": ["Read", "Update"],
|
||
"ReadDataHistory": ["Read"],
|
||
"ReadDataHistoryOfMissingData": ["Read", "ReadDataHistory"],
|
||
"Start": ["Read", "Update"],
|
||
"SwitchToDataHistoryVersion": ["Read", "View"],
|
||
"UndoPosting": ["Read", "Update"],
|
||
"Update": ["Read"],
|
||
"UpdateDataHistory": ["Read", "ReadDataHistory"],
|
||
"UpdateDataHistoryOfMissingData": ["Read", "ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory"],
|
||
"UpdateDataHistoryVersionComment": ["Read", "ReadDataHistory"],
|
||
"View": ["Read"],
|
||
"ViewDataHistory": ["Read", "ReadDataHistory", "View"],
|
||
}
|
||
|
||
RIGHT_DEPS_BY_TYPE = {
|
||
"ChartOfAccounts": {
|
||
"ReadDataHistory": [],
|
||
"ReadDataHistoryOfMissingData": ["ReadDataHistory"],
|
||
"UpdateDataHistory": ["ReadDataHistory"],
|
||
"UpdateDataHistoryOfMissingData": ["ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory"],
|
||
"UpdateDataHistoryVersionComment": ["ReadDataHistory"],
|
||
},
|
||
"DataProcessor": {
|
||
"View": ["Use"],
|
||
},
|
||
"InformationRegister": {
|
||
"UpdateDataHistoryOfMissingData": ["Read", "ReadDataHistory", "UpdateDataHistory"],
|
||
},
|
||
"Report": {
|
||
"View": ["Use"],
|
||
},
|
||
}
|
||
|
||
CONFIGURATION_LEGACY_DEPS = ["AnalyticsSystemClient", "MainWindowModeEmbeddedWorkplace", "MainWindowModeFullscreenWorkplace", "MainWindowModeKiosk", "MainWindowModeNormal", "MainWindowModeWorkplace"]
|
||
|
||
# Права конфигурации: до формата 2.19 платформа взводила весь блок режимов окна вместе с
|
||
# любым правом, с 2.19 (8.3.26) перестала. Сами права допустимы и там, и там.
|
||
CONFIGURATION_LEGACY_RANK = 218
|
||
|
||
|
||
# Платформа хранит только то, что ОТЛИЧАЕТСЯ от значения по умолчанию для роли: при
|
||
# setForNewObjects=false на верхнем уровне живут разрешения, при true — запреты; у реквизитных
|
||
# вложенных объектов ту же роль играет setForAttributesByDefault. Совпавшее с умолчанием
|
||
# платформа выбрасывает при первой же загрузке, поэтому не пишем его и сами.
|
||
ATTRIBUTE_KINDS = [
|
||
"Attribute", "StandardAttribute", "TabularSection", "StandardTabularSection",
|
||
"Dimension", "Resource", "AccountingFlag", "ExtDimensionAccountingFlag", "AddressingAttribute",
|
||
]
|
||
|
||
|
||
def get_default_right_value(object_name, set_for_new_objects, set_for_attributes_by_default):
|
||
parts = object_name.split('.')
|
||
if len(parts) < 3:
|
||
return set_for_new_objects
|
||
# Внешние источники данных под это правило не проверялись — трогаем только то, что замерено.
|
||
if parts[0] == 'ExternalDataSource':
|
||
return "false"
|
||
kind = parts[-2]
|
||
if kind in ATTRIBUTE_KINDS:
|
||
return set_for_attributes_by_default
|
||
# Команды, подсистемы, операции сервисов флагами роли не управляются — там живут разрешения.
|
||
return "false"
|
||
|
||
|
||
def close_rights_dependencies(object_name, rights, format_rank):
|
||
"""Замыкание набора прав объекта. Возвращает (итоговые права, что дописано)."""
|
||
parts = object_name.split('.')
|
||
nested = len(parts) >= 3
|
||
object_type = parts[0]
|
||
allowed = (get_nested_rights(object_type, get_nested_kind(object_name)) if nested
|
||
else KNOWN_RIGHTS.get(object_type))
|
||
if not allowed:
|
||
return rights, []
|
||
have = {}
|
||
for r in rights:
|
||
have.setdefault(r['Name'], r)
|
||
by_type = RIGHT_DEPS_BY_TYPE.get(object_type, {})
|
||
added = []
|
||
# Вперёд — только от РАЗРЕШЁННЫХ прав: платформа замыкает выданное, а не запрещённое.
|
||
queue = [n for n in have if have[n]['Value'] == 'true']
|
||
while queue:
|
||
name = queue.pop(0)
|
||
need = by_type[name] if name in by_type else RIGHT_DEPS.get(name)
|
||
if not need:
|
||
continue
|
||
for dep in need:
|
||
if dep not in allowed:
|
||
continue
|
||
if dep in have:
|
||
# Разрешение перебивает запрет — так поступает и платформа при загрузке.
|
||
if have[dep]['Value'] != 'true':
|
||
have[dep]['Value'] = 'true'
|
||
added.append(dep)
|
||
queue.append(dep)
|
||
continue
|
||
have[dep] = {'Name': dep, 'Value': 'true', 'Condition': None}
|
||
added.append(dep)
|
||
queue.append(dep)
|
||
# Назад — от ЗАПРЕТОВ: право, которому запрещённое нужно, платформа запрещает следом.
|
||
deny_queue = [n for n in have if have[n]['Value'] != 'true']
|
||
while deny_queue:
|
||
name = deny_queue.pop(0)
|
||
for candidate in allowed:
|
||
if candidate == name or candidate in have:
|
||
continue
|
||
need = by_type[candidate] if candidate in by_type else RIGHT_DEPS.get(candidate)
|
||
if not need or name not in need:
|
||
continue
|
||
have[candidate] = {'Name': candidate, 'Value': 'false', 'Condition': None}
|
||
added.append(candidate)
|
||
deny_queue.append(candidate)
|
||
if object_type == 'Configuration' and format_rank <= CONFIGURATION_LEGACY_RANK and have:
|
||
for dep in CONFIGURATION_LEGACY_DEPS:
|
||
if dep in have:
|
||
continue
|
||
have[dep] = {'Name': dep, 'Value': 'true', 'Condition': None}
|
||
added.append(dep)
|
||
return list(have.values()), added
|
||
|
||
|
||
# --- Канонический порядок прав и узлов (замерено на платформе) ---
|
||
# Платформа нормализует порядок <right> внутри <object> и порядок самих <object>:
|
||
# права идут в фиксированном для типа порядке, узлы — по uuid объекта метаданных.
|
||
# Пишем сразу так же, иначе первая же выгрузка из Конфигуратора даст диф на ровном месте.
|
||
RIGHT_ORDER = {
|
||
"AccountingRegister": ["Read", "Update", "View", "Edit", "TotalsControl"],
|
||
"AccumulationRegister": ["Read", "Update", "View", "Edit", "TotalsControl"],
|
||
"BusinessProcess": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveActivate", "Start", "InteractiveStart", "ReadDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings",
|
||
"UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"CalculationRegister": ["Read", "Update", "View", "Edit"],
|
||
"Catalog": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfAccounts": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfCalculationTypes": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"ChartOfCharacteristicTypes": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveDeletePredefinedData", "InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData", "InteractiveDeleteMarkedPredefinedData",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"CommonAttribute": ["View", "Edit"],
|
||
"CommonCommand": ["View"],
|
||
"CommonForm": ["View"],
|
||
"Configuration": [
|
||
"Administration", "DataAdministration", "UpdateDataBaseConfiguration", "ExclusiveMode",
|
||
"ActiveUsers", "EventLog", "ThinClient", "WebClient",
|
||
"MobileClient", "ThickClient", "ExternalConnection", "Automation",
|
||
"TechnicalSpecialistMode", "CollaborationSystemInfoBaseRegistration", "MainWindowModeNormal", "MainWindowModeWorkplace",
|
||
"MainWindowModeEmbeddedWorkplace", "MainWindowModeFullscreenWorkplace", "MainWindowModeKiosk", "AnalyticsSystemClient",
|
||
"SaveUserData", "ConfigurationExtensionsAdministration", "InteractiveOpenExtDataProcessors", "InteractiveOpenExtReports",
|
||
"Output",
|
||
],
|
||
"Constant": [
|
||
"Read", "Update", "View", "Edit",
|
||
"ReadDataHistory", "UpdateDataHistory", "UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"DataProcessor": ["Use", "View"],
|
||
"Document": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"Posting", "UndoPosting", "View", "InteractiveInsert",
|
||
"Edit", "InteractiveDelete", "InteractiveSetDeletionMark", "InteractiveClearDeletionMark",
|
||
"InteractiveDeleteMarked", "InteractivePosting", "InteractivePostingRegular", "InteractiveUndoPosting",
|
||
"InteractiveChangeOfPosted", "InputByString", "ReadDataHistory", "ReadDataHistoryOfMissingData",
|
||
"UpdateDataHistory", "UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings", "UpdateDataHistoryVersionComment",
|
||
"ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"DocumentJournal": ["Read", "View"],
|
||
"ExchangePlan": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData",
|
||
"UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment",
|
||
"SwitchToDataHistoryVersion",
|
||
],
|
||
"FilterCriterion": ["View"],
|
||
"HTTPService": ["Use"],
|
||
"InformationRegister": [
|
||
"Read", "Update", "View", "Edit",
|
||
"TotalsControl", "ReadDataHistory", "ReadDataHistoryOfMissingData", "UpdateDataHistory",
|
||
"UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings", "UpdateDataHistoryVersionComment", "ViewDataHistory",
|
||
"EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"IntegrationService": ["Use"],
|
||
"Report": ["Use", "View"],
|
||
"Sequence": ["Read", "Update"],
|
||
"SessionParameter": ["Get", "Set"],
|
||
"Subsystem": ["View"],
|
||
"Task": [
|
||
"Read", "Insert", "Update", "Delete",
|
||
"View", "InteractiveInsert", "Edit", "InteractiveDelete",
|
||
"InteractiveSetDeletionMark", "InteractiveClearDeletionMark", "InteractiveDeleteMarked", "InputByString",
|
||
"InteractiveActivate", "Execute", "InteractiveExecute", "ReadDataHistory",
|
||
"ReadDataHistoryOfMissingData", "UpdateDataHistory", "UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings",
|
||
"UpdateDataHistoryVersionComment", "ViewDataHistory", "EditDataHistoryVersionComment", "SwitchToDataHistoryVersion",
|
||
],
|
||
"WebService": ["Use"],
|
||
}
|
||
|
||
NESTED_RIGHT_ORDER = {
|
||
"AccountingFlag": ["View", "Edit"],
|
||
"AddressingAttribute": ["View", "Edit"],
|
||
"Attribute": ["View", "Edit"],
|
||
"Command": ["View"],
|
||
"Dimension": ["View", "Edit"],
|
||
"ExtDimensionAccountingFlag": ["View", "Edit"],
|
||
"IntegrationServiceChannel": ["Use"],
|
||
"Method": ["Use"],
|
||
"Operation": ["Use"],
|
||
"Recalculation": ["Read", "Update"],
|
||
"Resource": ["View", "Edit"],
|
||
"StandardAttribute": ["View", "Edit"],
|
||
"StandardTabularSection": ["View", "Edit"],
|
||
"Subsystem": ["View"],
|
||
"TabularSection": ["View", "Edit"],
|
||
}
|
||
|
||
# Каталоги объектов метаданных — нужны, чтобы прочитать uuid и расставить <object>.
|
||
TYPE_DIRS = {
|
||
"Catalog": "Catalogs", "Document": "Documents", "DocumentJournal": "DocumentJournals",
|
||
"Sequence": "Sequences", "Constant": "Constants", "Report": "Reports",
|
||
"DataProcessor": "DataProcessors", "InformationRegister": "InformationRegisters",
|
||
"AccumulationRegister": "AccumulationRegisters", "AccountingRegister": "AccountingRegisters",
|
||
"CalculationRegister": "CalculationRegisters", "ChartOfAccounts": "ChartsOfAccounts",
|
||
"ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
|
||
"ChartOfCalculationTypes": "ChartsOfCalculationTypes", "ExchangePlan": "ExchangePlans",
|
||
"BusinessProcess": "BusinessProcesses", "Task": "Tasks", "Subsystem": "Subsystems",
|
||
"CommonForm": "CommonForms", "CommonCommand": "CommonCommands",
|
||
"CommonAttribute": "CommonAttributes", "FilterCriterion": "FilterCriteria",
|
||
"SessionParameter": "SessionParameters", "WebService": "WebServices",
|
||
"HTTPService": "HTTPServices", "IntegrationService": "IntegrationServices",
|
||
"ExternalDataSource": "ExternalDataSources",
|
||
}
|
||
|
||
|
||
def sort_rights_canonical(object_name, rights):
|
||
"""Порядок прав объекта: известные — по таблице, незнакомые — следом, в порядке ввода."""
|
||
parts = object_name.split('.')
|
||
order = NESTED_RIGHT_ORDER.get(parts[-2]) if len(parts) >= 3 else RIGHT_ORDER.get(parts[0])
|
||
if not order:
|
||
return rights
|
||
by_name = {}
|
||
for r in rights:
|
||
by_name.setdefault(r['Name'], r)
|
||
sorted_rights = []
|
||
for name in order:
|
||
if name in by_name:
|
||
sorted_rights.append(by_name.pop(name))
|
||
for r in rights:
|
||
if r['Name'] in by_name:
|
||
sorted_rights.append(by_name.pop(r['Name']))
|
||
return sorted_rights
|
||
|
||
|
||
# У стандартных реквизитов и стандартных табличных частей uuid в выгрузке нет: они системные.
|
||
# Отсутствие uuid для них — норма, а не потерянный объект.
|
||
def is_standard_kind(object_name):
|
||
parts = object_name.split('.')
|
||
if len(parts) < 3:
|
||
return False
|
||
return parts[-2].startswith("Standard")
|
||
|
||
|
||
# uuid объекта прав: у верхнего уровня — из файла объекта, у вложенного — спуском по дереву.
|
||
# Искать регуляркой по всему файлу нельзя: реквизит шапки и реквизит табличной части часто
|
||
# называются одинаково, и поиск нашёл бы первый попавшийся. Дочерние подсистемы лежат
|
||
# отдельными файлами, поэтому для них спуск идёт по каталогам.
|
||
# У стандартных реквизитов uuid в выгрузке нет вовсе — для них возвращаем None молча.
|
||
def get_rights_object_uuid(object_name, config_root):
|
||
parts = object_name.split('.')
|
||
if parts[0] == 'Configuration':
|
||
cfg_path = os.path.join(config_root, 'Configuration.xml')
|
||
if not os.path.isfile(cfg_path):
|
||
return None
|
||
with open(cfg_path, 'r', encoding='utf-8-sig') as f:
|
||
m = re.search(r'<Configuration uuid="([0-9a-fA-F-]+)"', f.read())
|
||
return m.group(1) if m else None
|
||
directory = TYPE_DIRS.get(parts[0])
|
||
if not directory or len(parts) < 2:
|
||
return None
|
||
# Подсистемы вложены каталогами: Subsystems/Родитель/Subsystems/Ребёнок.xml
|
||
owner_path = os.path.join(config_root, directory, parts[1] + '.xml')
|
||
i = 2
|
||
while len(parts) > i + 1 and parts[i] == 'Subsystem':
|
||
owner_path = os.path.join(os.path.splitext(owner_path)[0], 'Subsystems', parts[i + 1] + '.xml')
|
||
i += 2
|
||
if not os.path.isfile(owner_path):
|
||
return None
|
||
try:
|
||
tree = etree.parse(owner_path)
|
||
except Exception:
|
||
return None
|
||
md = '{http://v8.1c.ru/8.3/MDClasses}'
|
||
node = tree.getroot()[0] if len(tree.getroot()) else None
|
||
if node is None:
|
||
return None
|
||
# Оставшиеся пары «вид, имя» ищем строго внутри текущего узла.
|
||
while i + 1 < len(parts):
|
||
kind, name = parts[i], parts[i + 1]
|
||
child = None
|
||
for candidate in node.findall(f'{md}ChildObjects/{md}{kind}'):
|
||
props = candidate.find(f'{md}Properties/{md}Name')
|
||
if props is not None and (props.text or '') == name:
|
||
child = candidate
|
||
break
|
||
if child is None:
|
||
return None
|
||
node = child
|
||
i += 2
|
||
return node.get('uuid')
|
||
|
||
|
||
def sort_objects_by_uuid(objects, config_root):
|
||
"""Порядок узлов: по uuid объекта; неразрешённые — в конец, в порядке ввода."""
|
||
known, unknown = [], []
|
||
for o in objects:
|
||
uuid_value = get_rights_object_uuid(o['Name'], config_root)
|
||
if uuid_value:
|
||
known.append((uuid_value, o))
|
||
else:
|
||
print(f"[role-compile] {o['Name']}: объект не найден в выгрузке, uuid неизвестен — "
|
||
f"узел записан в конец (платформа переставит его при первой выгрузке)",
|
||
file=sys.stderr)
|
||
unknown.append(o)
|
||
known.sort(key=lambda pair: pair[0])
|
||
return [o for _, o in known] + unknown
|
||
|
||
|
||
# Отказ копится, а не печатается сразу: роль пишется целиком, поэтому единственный
|
||
# безопасный момент отказа — до первой записи, и показать надо все причины сразу.
|
||
VALIDATION_ERRORS = []
|
||
|
||
|
||
def add_validation_error(message):
|
||
VALIDATION_ERRORS.append(message)
|
||
|
||
|
||
def validate_object_name(object_name):
|
||
"""Тип по белому списку (всегда, включая вложенные пути) и вид вложенности.
|
||
Запрещённый и незнакомый тип — разные диагнозы."""
|
||
object_type = get_object_type(object_name)
|
||
if object_type not in KNOWN_RIGHTS:
|
||
if object_type in NO_RIGHTS_TYPES:
|
||
add_validation_error(f"{object_name}: тип '{object_type}' не имеет прав в роли — уберите объект из списка")
|
||
else:
|
||
similar = [t for t in KNOWN_RIGHTS if object_type in t or t in object_type][:3]
|
||
sug = f" Возможно: {', '.join(similar)}?" if similar else ''
|
||
add_validation_error(f"{object_name}: неизвестный тип объекта '{object_type}'.{sug}")
|
||
return False
|
||
|
||
if is_nested_object(object_name):
|
||
kind = get_nested_kind(object_name)
|
||
if kind in KIND_OWNERS and object_type != KIND_OWNERS[kind]:
|
||
add_validation_error(f"{object_name}: вид '{kind}' бывает только у {KIND_OWNERS[kind]}")
|
||
return False
|
||
if get_nested_rights(object_type, kind) is None:
|
||
add_validation_error(f"{object_name}: неизвестный вид вложенности '{kind}'")
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def resolve_preset(object_type, preset_name):
|
||
preset = preset_name.lstrip('@')
|
||
if preset not in PRESETS:
|
||
print(f"WARNING: Unknown preset '@{preset}'. Known: @view, @edit", file=sys.stderr)
|
||
return []
|
||
type_map = PRESETS[preset]
|
||
if object_type not in type_map:
|
||
available = []
|
||
for k in PRESETS:
|
||
if object_type in PRESETS[k]:
|
||
available.append(f'@{k}')
|
||
avail_str = ', '.join(available) if available else 'none'
|
||
print(f"WARNING: Preset '@{preset}' not defined for type '{object_type}'. Available: {avail_str}", file=sys.stderr)
|
||
return []
|
||
return list(type_map[object_type])
|
||
|
||
|
||
def validate_right_name(object_name, right_name):
|
||
object_type = get_object_type(object_name)
|
||
|
||
# Тип уже проверен validate_object_name — здесь только права, иначе про один
|
||
# запрещённый тип напечатается столько строк, сколько у него перечислено прав.
|
||
if object_type not in KNOWN_RIGHTS:
|
||
return False
|
||
|
||
if is_nested_object(object_name):
|
||
kind = get_nested_kind(object_name)
|
||
valid_nested = get_nested_rights(object_type, kind)
|
||
if valid_nested is None:
|
||
return False
|
||
if right_name not in valid_nested:
|
||
add_validation_error(f"{object_name}: право '{right_name}' недопустимо для вида '{kind}' (допустимо: {', '.join(valid_nested)})")
|
||
return False
|
||
return True
|
||
|
||
valid_rights = KNOWN_RIGHTS[object_type]
|
||
if right_name not in valid_rights:
|
||
suggestions = [r for r in valid_rights if right_name in r or r in right_name][:3]
|
||
sug_str = f" Возможно: {', '.join(suggestions)}?" if suggestions else ""
|
||
add_validation_error(f"{object_name}: право '{right_name}' не существует у типа '{object_type}'.{sug_str}")
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
# "@путь" в значении условия — текст берётся из файла: условия RLS типовых занимают десятки
|
||
|
||
def resolve_text_from_file(val, base_dir):
|
||
if not val.startswith("@"):
|
||
return val
|
||
file_path = val[1:]
|
||
if os.path.isabs(file_path):
|
||
candidates = [file_path]
|
||
else:
|
||
candidates = [
|
||
os.path.join(base_dir, file_path),
|
||
os.path.join(os.getcwd(), file_path),
|
||
]
|
||
for c in candidates:
|
||
if os.path.exists(c):
|
||
with open(c, 'r', encoding='utf-8-sig') as f:
|
||
return f.read().rstrip()
|
||
print(f"Файл значения не найден: {file_path} (искали: {', '.join(candidates)})", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
TEXT_BASE_DIR = os.getcwd()
|
||
|
||
MD_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||
|
||
# Метаданные сервиса читаются один раз на имя: раскрытие и проверка заимствования
|
||
# спрашивают один и тот же файл.
|
||
SERVICE_META_CACHE = {}
|
||
|
||
|
||
def get_service_meta(object_type, service_name, config_root):
|
||
key = f"{object_type}.{service_name}"
|
||
if key in SERVICE_META_CACHE:
|
||
return SERVICE_META_CACHE[key]
|
||
|
||
spec = SERVICE_LEAVES[object_type]
|
||
xml_path = os.path.join(config_root, spec['dir'], f"{service_name}.xml")
|
||
result = {'path': xml_path, 'found': False, 'adopted': False, 'leaves': []}
|
||
|
||
if os.path.isfile(xml_path):
|
||
try:
|
||
root = etree.parse(xml_path).getroot()
|
||
node = root.find(f"{{{MD_NS}}}{object_type}")
|
||
if node is not None:
|
||
result['found'] = True
|
||
# ObjectBelonging=Adopted — сервис заимствован в расширение.
|
||
ob = node.find(f"{{{MD_NS}}}Properties/{{{MD_NS}}}ObjectBelonging")
|
||
if ob is not None and (ob.text or '') == 'Adopted':
|
||
result['adopted'] = True
|
||
|
||
# Спуск по видам: у HTTP-сервиса лист лежит на два уровня ниже
|
||
# (URLTemplate → Method), у остальных — на один.
|
||
level = [(node, f"{object_type}.{service_name}")]
|
||
for kind in spec['kinds']:
|
||
nxt = []
|
||
for item_node, item_name in level:
|
||
for child in item_node.findall(f"{{{MD_NS}}}ChildObjects/{{{MD_NS}}}{kind}"):
|
||
name_node = child.find(f"{{{MD_NS}}}Properties/{{{MD_NS}}}Name")
|
||
if name_node is None:
|
||
continue
|
||
nxt.append((child, f"{item_name}.{kind}.{name_node.text}"))
|
||
level = nxt
|
||
result['leaves'] = [n for _, n in level]
|
||
except Exception:
|
||
# Битый XML — не наша забота: раскрывать нечего, дальше отработает отказ
|
||
# «метаданные не найдены» с тем же путём в подсказке.
|
||
pass
|
||
|
||
SERVICE_META_CACHE[key] = result
|
||
return result
|
||
|
||
|
||
def get_service_leaf_hint(object_type, service_name):
|
||
"""Подсказка формата: единственное, что отличается у трёх видов сервисов, — путь до листа."""
|
||
if object_type == 'HTTPService':
|
||
return f"{object_type}.{service_name}.URLTemplate.<Шаблон>.Method.<Метод>: Use"
|
||
if object_type == 'WebService':
|
||
return f"{object_type}.{service_name}.Operation.<Операция>: Use"
|
||
return f"{object_type}.{service_name}.IntegrationServiceChannel.<Канал>: Use"
|
||
|
||
|
||
# Роль расширения, включённая в <DefaultRoles>, прав на заимствованные объекты давать не
|
||
# может — платформа отвечает «Назначение прав доступа на заимствованные объекты основными
|
||
# ролями в расширениях недопустимо». Считаем один раз: имя роли за прогон не меняется.
|
||
IS_DEFAULT_ROLE = None
|
||
|
||
|
||
def test_default_role(config_root, name):
|
||
global IS_DEFAULT_ROLE
|
||
if IS_DEFAULT_ROLE is not None:
|
||
return IS_DEFAULT_ROLE
|
||
IS_DEFAULT_ROLE = False
|
||
|
||
cfg_path = os.path.join(config_root, 'Configuration.xml')
|
||
if os.path.isfile(cfg_path):
|
||
with open(cfg_path, 'r', encoding='utf-8-sig') as f:
|
||
text = f.read()
|
||
# Только расширение: у обычной конфигурации DefaultRoles значит другое и запрета нет.
|
||
if '<ConfigurationExtensionPurpose>' in text:
|
||
m = re.search(r'<DefaultRoles>(.*?)</DefaultRoles>', text, re.S)
|
||
# Сравнение регистрозависимое — паритет с -cmatch в PS1, где регистронезависимый
|
||
# -match принял бы «расш1_роль1» за основную роль «Расш1_Роль1».
|
||
if m and re.search(re.escape(f"Role.{name}") + r'\s*<', m.group(1)):
|
||
IS_DEFAULT_ROLE = True
|
||
return IS_DEFAULT_ROLE
|
||
|
||
|
||
def expand_service_entry(parsed, config_root, name):
|
||
"""Возвращает список записей на замену исходной: сервисный корень раскрывается в листья,
|
||
всё остальное проходит как есть."""
|
||
obj_name = parsed['Name']
|
||
object_type = get_object_type(obj_name)
|
||
if object_type not in SERVICE_LEAVES:
|
||
return [parsed]
|
||
|
||
parts = obj_name.split('.')
|
||
if len(parts) < 2:
|
||
return [parsed]
|
||
service_name = parts[1]
|
||
meta = get_service_meta(object_type, service_name, config_root)
|
||
|
||
if meta['adopted'] and test_default_role(config_root, name):
|
||
add_validation_error(
|
||
f"{obj_name}: '{name}' — основная роль расширения (входит в DefaultRoles), "
|
||
f"а {object_type}.{service_name} заимствован; назначать права на заимствованные объекты "
|
||
"основными ролями расширения платформа запрещает. Заведите отдельную роль и не включайте её в основные.")
|
||
return []
|
||
|
||
# Полный путь пользователь задал сам — раскрывать нечего.
|
||
if len(parts) > 2:
|
||
return [parsed]
|
||
|
||
hint = get_service_leaf_hint(object_type, service_name)
|
||
if not meta['found']:
|
||
add_validation_error(
|
||
f"{obj_name}: метаданные сервиса не найдены ({meta['path']}); "
|
||
f"право на сервис целиком платформа игнорирует — укажите листья явно: {hint}")
|
||
return []
|
||
if not meta['leaves']:
|
||
add_validation_error(
|
||
f"{obj_name}: у сервиса нет ни одного вложенного объекта, раскрывать нечего; "
|
||
"право на сервис целиком платформа игнорирует. "
|
||
f"Для заимствованного сервиса заимствуйте нужные методы, затем: {hint}")
|
||
return []
|
||
|
||
expanded = [{'Name': leaf, 'Rights': parsed['Rights']} for leaf in meta['leaves']]
|
||
print(f" {obj_name} -> раскрыт (вложенных объектов: {len(expanded)})")
|
||
return expanded
|
||
|
||
|
||
|
||
def esc_xml(s):
|
||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||
|
||
|
||
def esc_xml_text(s):
|
||
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||
|
||
|
||
|
||
def get_child_indent(container):
|
||
"""Detect indentation of children inside a container element."""
|
||
if container.text and "\n" in container.text:
|
||
after_nl = container.text.rsplit("\n", 1)[-1]
|
||
if after_nl and not after_nl.strip():
|
||
return after_nl
|
||
for child in container:
|
||
if child.tail and "\n" in child.tail:
|
||
after_nl = child.tail.rsplit("\n", 1)[-1]
|
||
if after_nl and not after_nl.strip():
|
||
return after_nl
|
||
# Fallback: count depth
|
||
depth = 0
|
||
current = container
|
||
while current is not None:
|
||
depth += 1
|
||
current = current.getparent()
|
||
return "\t" * depth
|
||
|
||
|
||
def insert_before_closing(container, new_el, child_indent):
|
||
"""Insert new_el before the closing tag of container, with proper indentation."""
|
||
children = list(container)
|
||
if len(children) == 0:
|
||
# Empty element: set text to newline+indent, tail of new_el to newline+parent_indent
|
||
parent_indent = child_indent[:-1] if len(child_indent) > 0 else ""
|
||
container.text = "\r\n" + child_indent
|
||
new_el.tail = "\r\n" + parent_indent
|
||
container.append(new_el)
|
||
else:
|
||
last = children[-1]
|
||
new_el.tail = last.tail
|
||
last.tail = "\r\n" + child_indent
|
||
container.append(new_el)
|
||
|
||
|
||
def remove_with_indent(el):
|
||
"""Remove element and clean up surrounding whitespace."""
|
||
parent = el.getparent()
|
||
prev = el.getprevious()
|
||
if prev is not None:
|
||
# Transfer el.tail to prev.tail
|
||
if el.tail and el.tail.strip() == "":
|
||
pass # just drop extra whitespace
|
||
prev.tail = el.tail if el.tail and el.tail.strip() else (prev.tail or "")
|
||
# Actually try to keep the prev's tail as the closing indent
|
||
# Better approach: set prev.tail to what el.tail was (newline+indent of next or closing)
|
||
if el.tail:
|
||
prev.tail = el.tail
|
||
else:
|
||
# First child: adjust parent.text
|
||
if el.tail:
|
||
parent.text = el.tail
|
||
parent.remove(el)
|
||
|
||
|
||
def expand_self_closing(container, parent_indent):
|
||
"""If container is self-closing (no children, no text), add closing whitespace."""
|
||
if len(container) == 0 and not (container.text and container.text.strip()):
|
||
container.text = "\r\n" + parent_indent
|
||
|
||
|
||
def import_fragment(xml_string, doc_root):
|
||
"""Parse an XML fragment in the MD namespace context and return elements."""
|
||
wrapper = (
|
||
f'<_W xmlns="{MD_NS}" xmlns:xsi="{XSI_NS}" xmlns:v8="{V8_NS}" '
|
||
f'xmlns:xr="{XR_NS}" xmlns:xs="{XS_NS}">{xml_string}</_W>'
|
||
)
|
||
frag = etree.fromstring(wrapper.encode("utf-8"))
|
||
nodes = []
|
||
for child in frag:
|
||
nodes.append(child)
|
||
return nodes
|
||
|
||
|
||
def parse_value_list(val, op_name):
|
||
"""Parse a string or JSON array into a list of strings."""
|
||
val = val.strip()
|
||
if val.startswith("["):
|
||
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of object names", inline=True))
|
||
return [str(item) for item in arr]
|
||
return [val]
|
||
|
||
|
||
|
||
def _detect_xml_style(path):
|
||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||
try:
|
||
raw = open(path, "rb").read()
|
||
except OSError:
|
||
return None
|
||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||
body = raw[3:] if bom else raw
|
||
crlf = b"\r\n" in body
|
||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||
final_nl = body.endswith(b"\n")
|
||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||
|
||
|
||
def _finalize_xml_bytes(xml_bytes, style):
|
||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||
enc_decl = style["enc"] if style else "UTF-8"
|
||
xml_bytes = xml_bytes.replace(
|
||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||
want_final_nl = style["final_nl"] if style else False
|
||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||
if want_final_nl:
|
||
xml_bytes += b"\n"
|
||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||
if (style["crlf"] if style else True):
|
||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||
return xml_bytes
|
||
|
||
|
||
def save_xml_bom(tree, path):
|
||
style = _detect_xml_style(path)
|
||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||
with open(path, "wb") as f:
|
||
if style is None or style["bom"]:
|
||
f.write(b"\xef\xbb\xbf")
|
||
f.write(xml_bytes)
|
||
|
||
ROLES_NS = "http://v8.1c.ru/8.2/roles"
|
||
MD_OBJECT_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||
|
||
# --- Стандартные реквизиты в списке полей RLS платформа пишет по-английски ---
|
||
FIELD_ALIASES = {
|
||
"Ссылка": "Ref", "Код": "Code", "Наименование": "Description", "Родитель": "Parent",
|
||
"Владелец": "Owner", "Дата": "Date", "Номер": "Number", "ПометкаУдаления": "DeletionMark",
|
||
"ЭтоГруппа": "IsFolder", "Проведен": "Posted", "Проведён": "Posted", "ВерсияДанных": "DataVersion",
|
||
"Предопределенный": "Predefined", "Предопределённый": "Predefined",
|
||
}
|
||
|
||
|
||
def translate_field_name(name):
|
||
for key, value in FIELD_ALIASES.items():
|
||
if key.lower() == name.lower():
|
||
return value
|
||
return name
|
||
|
||
|
||
def rt(tag):
|
||
return "{%s}%s" % (ROLES_NS, tag)
|
||
|
||
|
||
def node_text(parent, tag):
|
||
child = parent.find(rt(tag))
|
||
return child.text or "" if child is not None else ""
|
||
|
||
|
||
# --- Резолв пути роли ---
|
||
# Принимаем всё, чем роль называют в обиходе: каталог роли, файл метаданных, сам Rights.xml.
|
||
def resolve_role_paths(input_path):
|
||
if not os.path.exists(input_path):
|
||
print(f"[role-edit] Путь не найден: {input_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
full = os.path.abspath(input_path)
|
||
rights_path = None
|
||
if os.path.isfile(full):
|
||
if os.path.basename(full) == "Rights.xml":
|
||
rights_path = full
|
||
else:
|
||
# Roles/Имя.xml — рядом лежит каталог Имя/Ext/Rights.xml
|
||
name = os.path.splitext(os.path.basename(full))[0]
|
||
rights_path = os.path.join(os.path.dirname(full), name, "Ext", "Rights.xml")
|
||
else:
|
||
for candidate in (os.path.join(full, "Ext", "Rights.xml"), os.path.join(full, "Rights.xml")):
|
||
if os.path.isfile(candidate):
|
||
rights_path = candidate
|
||
break
|
||
if not rights_path or not os.path.isfile(rights_path):
|
||
print(f"[role-edit] Rights.xml не найден для пути: {input_path}", file=sys.stderr)
|
||
print(" Ожидается каталог роли, Roles/Имя.xml или Roles/Имя/Ext/Rights.xml.", file=sys.stderr)
|
||
sys.exit(1)
|
||
rights_path = os.path.abspath(rights_path)
|
||
# Rights.xml лежит в <Roles>/<Имя>/Ext/, метаданные — в <Roles>/<Имя>.xml
|
||
role_dir = os.path.dirname(os.path.dirname(rights_path))
|
||
role_name = os.path.basename(role_dir)
|
||
roles_dir = os.path.dirname(role_dir)
|
||
return {
|
||
"RightsPath": rights_path,
|
||
"RoleXmlPath": os.path.join(roles_dir, role_name + ".xml"),
|
||
"RoleName": role_name,
|
||
"ConfigRoot": os.path.dirname(roles_dir),
|
||
}
|
||
|
||
|
||
class Editor:
|
||
"""Состояние правки: дерево прав, счётчики, отложенные операции."""
|
||
|
||
def __init__(self, paths, text_base_dir):
|
||
self.paths = paths
|
||
# База относительного пути @файла: каталог списка операций, иначе каталог самой роли.
|
||
# Текущий каталог функция проверяет вторым кандидатом в любом случае.
|
||
self.text_base_dir = text_base_dir
|
||
parser = etree.XMLParser(remove_blank_text=False)
|
||
self.tree = etree.parse(paths["RightsPath"], parser)
|
||
self.root = self.tree.getroot()
|
||
self.format_version = self.root.get("version") or "2.17"
|
||
self.format_rank = format_rank(self.format_version)
|
||
self.meta_tree = None
|
||
self.rights_dirty = False
|
||
self.meta_dirty = False
|
||
self.add_count = 0
|
||
self.remove_count = 0
|
||
self.modify_count = 0
|
||
self.notes = []
|
||
self.pending = []
|
||
# Умолчания роли решают, какие записи платформа хранит: совпавшее с умолчанием она выбрасывает.
|
||
self.role_sfno = node_text(self.root, "setForNewObjects")
|
||
self.role_sfab = node_text(self.root, "setForAttributesByDefault")
|
||
self.dropped_by_default = []
|
||
|
||
def note(self, text):
|
||
self.notes.append(text)
|
||
|
||
def right_stored(self, obj_name, right_name, value):
|
||
if value != get_default_right_value(obj_name, self.role_sfno, self.role_sfab):
|
||
return True
|
||
self.dropped_by_default.append(f"{obj_name}.{right_name}")
|
||
return False
|
||
|
||
# --- Разбор значений операций ---
|
||
|
||
def parse_batch(self, value):
|
||
# Делим ДО чтения файлов, поэтому ';;' внутри условия из файла разделителем не становится.
|
||
return [part.strip() for part in value.split(";;") if part.strip()]
|
||
|
||
|
||
@staticmethod
|
||
def split_at_top_level_colon(text, open_char, close_char):
|
||
depth = 0
|
||
for i, ch in enumerate(text):
|
||
if ch == open_char:
|
||
depth += 1
|
||
elif ch == close_char:
|
||
if depth > 0:
|
||
depth -= 1
|
||
elif ch == ":" and depth == 0:
|
||
return text[:i].strip(), text[i + 1:].strip(), True
|
||
return text.strip(), "", False
|
||
|
||
def parse_rights_spec(self, text, allow_no_rights=False):
|
||
left, right, found = self.split_at_top_level_colon(text, "[", "]")
|
||
if not found:
|
||
if not allow_no_rights:
|
||
add_validation_error(f"{text} : ожидается 'Тип.Имя: Право1, Право2' или 'Тип.Имя: @пресет'")
|
||
return None
|
||
obj_name = translate_object_name(left)
|
||
if not validate_object_name(obj_name):
|
||
return None
|
||
return {"Name": obj_name, "Rights": []}
|
||
obj_name = translate_object_name(left)
|
||
if not validate_object_name(obj_name):
|
||
return None
|
||
object_type = get_object_type(obj_name)
|
||
if right.startswith("@"):
|
||
right_names = resolve_preset(object_type, right)
|
||
else:
|
||
right_names = [translate_right_name(r.strip()) for r in right.split(",") if r.strip()]
|
||
valid = [r for r in right_names if validate_right_name(obj_name, r)]
|
||
return {"Name": obj_name, "Rights": valid}
|
||
|
||
def parse_rls_address(self, text, condition_required=False):
|
||
address, condition, found = self.split_at_top_level_colon(text, "[", "]")
|
||
if condition_required and not found:
|
||
add_validation_error(f"{text} : ожидается 'Тип.Имя.Право: условие' (условие может быть пустым)")
|
||
return None
|
||
fields = []
|
||
if address.endswith("]"):
|
||
open_idx = address.rfind("[")
|
||
if open_idx < 0:
|
||
add_validation_error(f"{text} : не закрыта скобка списка полей")
|
||
return None
|
||
fields_part = address[open_idx + 1:-1]
|
||
address = address[:open_idx].strip()
|
||
fields = [translate_field_name(f.strip()) for f in fields_part.split(",") if f.strip()]
|
||
if not fields:
|
||
add_validation_error(f"{text} : пустой список полей — уберите скобки, если ограничение на все поля")
|
||
return None
|
||
last_dot = address.rfind(".")
|
||
if last_dot < 1:
|
||
add_validation_error(f"{text} : ожидается 'Тип.Имя.Право', последний сегмент — имя права")
|
||
return None
|
||
obj_name = translate_object_name(address[:last_dot])
|
||
right_name = translate_right_name(address[last_dot + 1:])
|
||
if not validate_object_name(obj_name):
|
||
return None
|
||
if not validate_right_name(obj_name, right_name):
|
||
# Показываем разбор: иначе непонятно, что навык откусил не тот сегмент.
|
||
add_validation_error(f"{text} : разобрано как объект '{obj_name}' и право '{right_name}'")
|
||
return None
|
||
return {"Object": obj_name, "Right": right_name, "Fields": fields,
|
||
"Condition": resolve_text_from_file(condition, self.text_base_dir)}
|
||
|
||
def parse_template_spec(self, text, name_only=False):
|
||
left, right, found = self.split_at_top_level_colon(text, "(", ")")
|
||
if name_only:
|
||
return {"Name": left, "Condition": None}
|
||
if not found:
|
||
add_validation_error(f"{text} : ожидается 'Имя(Параметры): условие'")
|
||
return None
|
||
return {"Name": left, "Condition": resolve_text_from_file(right, self.text_base_dir)}
|
||
|
||
# --- Доступ к дереву прав ---
|
||
|
||
def object_nodes(self):
|
||
return self.root.findall(rt("object"))
|
||
|
||
def find_object(self, name):
|
||
for node in self.object_nodes():
|
||
if node_text(node, "name").lower() == name.lower():
|
||
return node
|
||
return None
|
||
|
||
@staticmethod
|
||
def right_nodes(obj_node):
|
||
return obj_node.findall(rt("right"))
|
||
|
||
def find_right(self, obj_node, right_name):
|
||
for node in self.right_nodes(obj_node):
|
||
if node_text(node, "name").lower() == right_name.lower():
|
||
return node
|
||
return None
|
||
|
||
def true_right_names(self, obj_node):
|
||
return [node_text(n, "name") for n in self.right_nodes(obj_node) if node_text(n, "value") == "true"]
|
||
|
||
@staticmethod
|
||
def child_indent(container):
|
||
return get_child_indent(container)
|
||
|
||
def insert_child(self, container, new_el, ref_el, child_indent):
|
||
"""Вставка с отступом: перед ref_el, либо последним ребёнком контейнера."""
|
||
parent_indent = child_indent[:-1] if len(child_indent) > 1 else ""
|
||
if ref_el is not None:
|
||
new_el.tail = "\r\n" + child_indent
|
||
ref_el.addprevious(new_el)
|
||
return
|
||
children = list(container)
|
||
if children:
|
||
new_el.tail = children[-1].tail
|
||
children[-1].tail = "\r\n" + child_indent
|
||
container.append(new_el)
|
||
else:
|
||
container.text = "\r\n" + child_indent
|
||
new_el.tail = "\r\n" + parent_indent
|
||
container.append(new_el)
|
||
|
||
@staticmethod
|
||
def remove_child(el):
|
||
remove_with_indent(el)
|
||
|
||
def make_right(self, name, value, indent):
|
||
xml = (f"<right>\r\n{indent}\t<name>{esc_xml_text(name)}</name>\r\n"
|
||
f"{indent}\t<value>{value}</value>\r\n{indent}</right>")
|
||
return self.fragment(xml)
|
||
|
||
def make_object(self, obj_name, indent):
|
||
xml = f"<object>\r\n{indent}\t<name>{esc_xml_text(obj_name)}</name>\r\n{indent}</object>"
|
||
return self.fragment(xml)
|
||
|
||
@staticmethod
|
||
def fragment(xml_string):
|
||
wrapper = f'<_W xmlns="{ROLES_NS}">{xml_string}</_W>'
|
||
parsed = etree.fromstring(wrapper.encode("utf-8"), etree.XMLParser(remove_blank_text=False))
|
||
return parsed[0]
|
||
|
||
# Порядок прав внутри узла у платформы фиксирован для типа — новое право встаёт на своё место.
|
||
def insert_right_canonical(self, obj_node, new_el, obj_name):
|
||
parts = obj_name.split(".")
|
||
order = NESTED_RIGHT_ORDER.get(parts[-2]) if len(parts) >= 3 else RIGHT_ORDER.get(parts[0])
|
||
new_name = node_text(new_el, "name")
|
||
ref = None
|
||
if order and new_name in order:
|
||
new_index = order.index(new_name)
|
||
for node in self.right_nodes(obj_node):
|
||
name = node_text(node, "name")
|
||
if name in order and order.index(name) > new_index:
|
||
ref = node
|
||
break
|
||
self.insert_child(obj_node, new_el, ref, self.child_indent(obj_node))
|
||
|
||
# Узлы <object> платформа держит в порядке uuid объекта метаданных.
|
||
def insert_object_node(self, new_el, obj_name):
|
||
indent = self.child_indent(self.root)
|
||
uuid_value = get_rights_object_uuid(obj_name, self.paths["ConfigRoot"])
|
||
ref = None
|
||
if uuid_value:
|
||
for node in self.object_nodes():
|
||
other = get_rights_object_uuid(node_text(node, "name"), self.paths["ConfigRoot"])
|
||
if other and other > uuid_value:
|
||
ref = node
|
||
break
|
||
elif not is_standard_kind(obj_name):
|
||
self.note(f"[WARN] {obj_name}: объект не найден в выгрузке, uuid неизвестен — "
|
||
f"узел записан перед шаблонами (платформа переставит его при первой выгрузке)")
|
||
if ref is None:
|
||
templates = self.root.findall(rt("restrictionTemplate"))
|
||
if templates:
|
||
ref = templates[0]
|
||
self.insert_child(self.root, new_el, ref, indent)
|
||
|
||
# Пустых узлов платформа не производит. Узел с одними запретами — производит (так закрывают
|
||
# реквизит), поэтому смотрим на наличие прав вообще, а не только разрешающих.
|
||
def remove_object_if_empty(self, obj_node):
|
||
if self.right_nodes(obj_node):
|
||
return
|
||
name = node_text(obj_node, "name")
|
||
self.remove_child(obj_node)
|
||
self.note(f" {name}: прав не осталось — узел объекта удалён")
|
||
|
||
# --- Зависимости ---
|
||
|
||
@staticmethod
|
||
def allowed_rights(obj_name):
|
||
parts = obj_name.split(".")
|
||
if len(parts) >= 3:
|
||
return get_nested_rights(parts[0], get_nested_kind(obj_name))
|
||
return KNOWN_RIGHTS.get(parts[0])
|
||
|
||
@staticmethod
|
||
def direct_deps(object_type, right_name):
|
||
by_type = RIGHT_DEPS_BY_TYPE.get(object_type, {})
|
||
if right_name in by_type:
|
||
return by_type[right_name]
|
||
return RIGHT_DEPS.get(right_name, [])
|
||
|
||
def dependent_rights(self, obj_name, right_name):
|
||
# Кто требует это право: снимаем его — обязаны снять и их, иначе платформа вернёт снятое.
|
||
# У вложенных объектов это работает и для запретов: View=false тянет Edit=false.
|
||
object_type = obj_name.split(".")[0]
|
||
allowed = self.allowed_rights(obj_name)
|
||
if not allowed:
|
||
return []
|
||
result = []
|
||
queue = [right_name]
|
||
while queue:
|
||
current = queue.pop(0)
|
||
for candidate in allowed:
|
||
if candidate in result or candidate == right_name:
|
||
continue
|
||
if current in self.direct_deps(object_type, candidate):
|
||
result.append(candidate)
|
||
queue.append(candidate)
|
||
return result
|
||
|
||
# --- Операции ---
|
||
|
||
def apply_add_rights(self, spec):
|
||
obj_node = self.find_object(spec["Name"])
|
||
created = False
|
||
if obj_node is None:
|
||
obj_node = self.make_object(spec["Name"], self.child_indent(self.root))
|
||
self.insert_object_node(obj_node, spec["Name"])
|
||
created = True
|
||
existing = [node_text(n, "name") for n in self.right_nodes(obj_node)]
|
||
wanted = list(spec["Rights"])
|
||
merged, seen = [], set()
|
||
for name in existing + wanted:
|
||
if name not in seen:
|
||
seen.add(name)
|
||
merged.append({"Name": name, "Value": "true", "Condition": None})
|
||
# Платформа при загрузке всё равно доведёт набор до замыкания — пишем его сразу.
|
||
closed, _ = close_rights_dependencies(spec["Name"], merged, self.format_rank)
|
||
added = []
|
||
indent = self.child_indent(obj_node)
|
||
for right in closed:
|
||
name = right["Name"]
|
||
if not self.right_stored(spec["Name"], name, "true"):
|
||
continue
|
||
node = self.find_right(obj_node, name)
|
||
if node is not None:
|
||
if node_text(node, "value") != "true":
|
||
node.find(rt("value")).text = "true"
|
||
added.append(name)
|
||
self.modify_count += 1
|
||
self.rights_dirty = True
|
||
continue
|
||
new_el = self.make_right(name, "true", indent)
|
||
self.insert_right_canonical(obj_node, new_el, spec["Name"])
|
||
added.append(name)
|
||
self.add_count += 1
|
||
self.rights_dirty = True
|
||
if created and not added:
|
||
self.remove_child(obj_node)
|
||
return
|
||
if added:
|
||
extra = [a for a in added if a not in wanted]
|
||
note = f" {spec['Name']}: добавлено — {', '.join(added)}"
|
||
if extra:
|
||
note += f" (по зависимости: {', '.join(extra)})"
|
||
self.note(note)
|
||
else:
|
||
self.note(f" {spec['Name']}: права уже выданы, изменений нет")
|
||
|
||
def apply_set_rights(self, spec):
|
||
obj_node = self.find_object(spec["Name"])
|
||
if obj_node is None:
|
||
self.apply_add_rights(spec)
|
||
return
|
||
dropped_rls = 0
|
||
for node in self.right_nodes(obj_node):
|
||
if node.find(rt("restrictionByCondition")) is not None:
|
||
dropped_rls += 1
|
||
self.remove_child(node)
|
||
self.remove_count += 1
|
||
closed, _ = close_rights_dependencies(
|
||
spec["Name"], [{"Name": r, "Value": "true", "Condition": None} for r in spec["Rights"]],
|
||
self.format_rank)
|
||
indent = self.child_indent(obj_node)
|
||
for right in closed:
|
||
if not self.right_stored(spec["Name"], right["Name"], "true"):
|
||
continue
|
||
new_el = self.make_right(right["Name"], "true", indent)
|
||
self.insert_right_canonical(obj_node, new_el, spec["Name"])
|
||
self.add_count += 1
|
||
self.rights_dirty = True
|
||
self.note(f" {spec['Name']}: набор прав заменён")
|
||
if dropped_rls:
|
||
self.note(f"[WARN] {spec['Name']}: снято ограничений RLS: {dropped_rls}")
|
||
self.remove_object_if_empty(obj_node)
|
||
|
||
def apply_remove_rights(self, spec):
|
||
obj_node = self.find_object(spec["Name"])
|
||
if obj_node is None:
|
||
self.note(f" {spec['Name']}: объекта нет в роли, пропуск")
|
||
return
|
||
if not spec["Rights"]:
|
||
self.remove_child(obj_node)
|
||
self.remove_count += 1
|
||
self.rights_dirty = True
|
||
self.note(f" {spec['Name']}: узел объекта удалён")
|
||
return
|
||
# Каскад: право, которое требует снимаемое, платформа вернула бы обратно.
|
||
to_remove = []
|
||
for right_name in spec["Rights"]:
|
||
to_remove.append(right_name)
|
||
for dependent in self.dependent_rights(spec["Name"], right_name):
|
||
if dependent not in to_remove:
|
||
to_remove.append(dependent)
|
||
removed = []
|
||
for right_name in to_remove:
|
||
node = self.find_right(obj_node, right_name)
|
||
if node is None:
|
||
continue
|
||
self.remove_child(node)
|
||
removed.append(right_name)
|
||
self.remove_count += 1
|
||
self.rights_dirty = True
|
||
if not removed:
|
||
self.note(f" {spec['Name']}: перечисленных прав нет, изменений нет")
|
||
return
|
||
cascade = [r for r in removed if r not in spec["Rights"]]
|
||
note = f" {spec['Name']}: снято — {', '.join(removed)}"
|
||
if cascade:
|
||
note += f" (каскадом: {', '.join(cascade)})"
|
||
self.note(note)
|
||
self.remove_object_if_empty(obj_node)
|
||
|
||
def apply_deny_rights(self, spec):
|
||
obj_node = self.find_object(spec["Name"])
|
||
created = False
|
||
if obj_node is None:
|
||
obj_node = self.make_object(spec["Name"], self.child_indent(self.root))
|
||
self.insert_object_node(obj_node, spec["Name"])
|
||
created = True
|
||
to_deny = []
|
||
for right_name in spec["Rights"]:
|
||
to_deny.append(right_name)
|
||
for dependent in self.dependent_rights(spec["Name"], right_name):
|
||
if dependent not in to_deny:
|
||
to_deny.append(dependent)
|
||
denied = []
|
||
indent = self.child_indent(obj_node)
|
||
for right_name in to_deny:
|
||
if not self.right_stored(spec["Name"], right_name, "false"):
|
||
continue
|
||
node = self.find_right(obj_node, right_name)
|
||
if node is not None:
|
||
if node_text(node, "value") == "false":
|
||
continue
|
||
node.find(rt("value")).text = "false"
|
||
self.modify_count += 1
|
||
else:
|
||
new_el = self.make_right(right_name, "false", indent)
|
||
self.insert_right_canonical(obj_node, new_el, spec["Name"])
|
||
self.add_count += 1
|
||
denied.append(right_name)
|
||
self.rights_dirty = True
|
||
if not denied:
|
||
if created:
|
||
self.remove_child(obj_node)
|
||
reason = ("запрет совпадает с умолчанием роли и платформой не хранится"
|
||
if any(d.startswith(spec['Name'] + '.') for d in self.dropped_by_default)
|
||
else "права уже запрещены")
|
||
self.note(f" {spec['Name']}: {reason}, изменений нет")
|
||
return
|
||
cascade = [r for r in denied if r not in spec["Rights"]]
|
||
note = f" {spec['Name']}: запрещено — {', '.join(denied)}"
|
||
if cascade:
|
||
note += f" (каскадом: {', '.join(cascade)})"
|
||
self.note(note)
|
||
|
||
# --- RLS ---
|
||
|
||
@staticmethod
|
||
def restriction_fields(node):
|
||
return [f.text or "" for f in node.findall(rt("field"))]
|
||
|
||
@staticmethod
|
||
def same_field_set(a, b):
|
||
return sorted(x.lower() for x in a) == sorted(x.lower() for x in b)
|
||
|
||
def make_restriction(self, indent, fields, condition):
|
||
# Поля платформа держит отсортированными ordinal, условие без полей идёт первой строкой.
|
||
inner = ""
|
||
for field in sorted(fields):
|
||
inner += f"{indent}\t<field>{esc_xml_text(field)}</field>\r\n"
|
||
if condition:
|
||
inner += f"{indent}\t<condition>{esc_xml_text(condition)}</condition>\r\n"
|
||
else:
|
||
inner += f"{indent}\t<condition/>\r\n"
|
||
return self.fragment(f"<restrictionByCondition>\r\n{inner}{indent}</restrictionByCondition>")
|
||
|
||
def apply_set_rls(self, spec):
|
||
obj_node = self.find_object(spec["Object"])
|
||
right_node = self.find_right(obj_node, spec["Right"]) if obj_node is not None else None
|
||
if right_node is None or node_text(right_node, "value") != "true":
|
||
add_validation_error(f"{spec['Object']}.{spec['Right']}: право не выдано — сначала add-rights, "
|
||
f"ограничение без права платформа игнорирует")
|
||
return
|
||
indent = self.child_indent(obj_node) + "\t"
|
||
existing = right_node.findall(rt("restrictionByCondition"))
|
||
target = None
|
||
for node in existing:
|
||
if self.same_field_set(self.restriction_fields(node), spec["Fields"]):
|
||
target = node
|
||
break
|
||
# Ссылка на шаблон, которого в роли нет, — тихая ошибка в рантайме 1С. Отказывать нельзя:
|
||
# шаблон могут добавить следующей операцией или следующим вызовом.
|
||
for m in re.finditer(r'#([A-Za-zА-Яа-яЁё0-9_]+)\s*\(', spec["Condition"] or ""):
|
||
template_name = m.group(1)
|
||
if template_name in ("Если", "Тогда", "Иначе", "КонецЕсли"):
|
||
continue
|
||
if self.find_template(template_name) is None:
|
||
print(f"[role-edit] {spec['Object']}.{spec['Right']}: условие ссылается на шаблон "
|
||
f"'{template_name}', которого в роли нет", file=sys.stderr)
|
||
new_el = self.make_restriction(indent, spec["Fields"], spec["Condition"])
|
||
if target is not None:
|
||
new_el.tail = target.tail
|
||
target.getparent().replace(target, new_el)
|
||
self.modify_count += 1
|
||
self.note(f" {spec['Object']}.{spec['Right']}: ограничение заменено")
|
||
else:
|
||
# Строка без полей («прочие поля») идёт первой, строки с полями — после неё.
|
||
ref = None
|
||
if not spec["Fields"]:
|
||
for node in existing:
|
||
if self.restriction_fields(node):
|
||
ref = node
|
||
break
|
||
self.insert_child(right_node, new_el, ref, indent)
|
||
self.add_count += 1
|
||
self.note(f" {spec['Object']}.{spec['Right']}: ограничение добавлено")
|
||
self.rights_dirty = True
|
||
|
||
def apply_remove_rls(self, spec):
|
||
obj_node = self.find_object(spec["Object"])
|
||
if obj_node is None:
|
||
self.note(f" {spec['Object']}: объекта нет в роли, пропуск")
|
||
return
|
||
right_node = self.find_right(obj_node, spec["Right"])
|
||
if right_node is None:
|
||
self.note(f" {spec['Object']}.{spec['Right']}: права нет в роли, пропуск")
|
||
return
|
||
removed = 0
|
||
for node in right_node.findall(rt("restrictionByCondition")):
|
||
# Адрес без скобок снимает все ограничения права, со скобками — строку с этим набором полей.
|
||
if spec["Fields"] and not self.same_field_set(self.restriction_fields(node), spec["Fields"]):
|
||
continue
|
||
self.remove_child(node)
|
||
removed += 1
|
||
if not removed:
|
||
self.note(f" {spec['Object']}.{spec['Right']}: ограничений нет, изменений нет")
|
||
return
|
||
self.remove_count += removed
|
||
self.rights_dirty = True
|
||
self.note(f" {spec['Object']}.{spec['Right']}: снято ограничений — {removed}")
|
||
|
||
# --- Шаблоны ---
|
||
|
||
@staticmethod
|
||
def template_identifier(name):
|
||
paren = name.find("(")
|
||
return name[:paren].strip() if paren > 0 else name.strip()
|
||
|
||
def find_template(self, name):
|
||
wanted = self.template_identifier(name).lower()
|
||
for node in self.root.findall(rt("restrictionTemplate")):
|
||
if self.template_identifier(node_text(node, "name")).lower() == wanted:
|
||
return node
|
||
return None
|
||
|
||
def apply_add_template(self, spec, allow_replace=False):
|
||
existing = self.find_template(spec["Name"])
|
||
if existing is not None and not allow_replace:
|
||
add_validation_error(f"{spec['Name']}: шаблон с таким именем уже есть — используйте set-template")
|
||
return
|
||
indent = self.child_indent(self.root)
|
||
xml = (f"<restrictionTemplate>\r\n{indent}\t<name>{esc_xml_text(spec['Name'])}</name>\r\n"
|
||
f"{indent}\t<condition>{esc_xml_text(spec['Condition'])}</condition>\r\n"
|
||
f"{indent}</restrictionTemplate>")
|
||
new_el = self.fragment(xml)
|
||
if existing is not None:
|
||
new_el.tail = existing.tail
|
||
existing.getparent().replace(existing, new_el)
|
||
self.modify_count += 1
|
||
self.note(f" {spec['Name']}: шаблон заменён")
|
||
else:
|
||
self.insert_child(self.root, new_el, None, indent)
|
||
self.add_count += 1
|
||
self.note(f" {spec['Name']}: шаблон добавлен")
|
||
self.rights_dirty = True
|
||
|
||
def apply_remove_template(self, spec):
|
||
node = self.find_template(spec["Name"])
|
||
if node is None:
|
||
self.note(f" {spec['Name']}: шаблона нет в роли, пропуск")
|
||
return
|
||
# Ссылка на удалённый шаблон — тихая ошибка в рантайме, поэтому показываем, кто им пользуется.
|
||
identifier = self.template_identifier(spec["Name"])
|
||
users = []
|
||
for obj_node in self.object_nodes():
|
||
for right_node in self.right_nodes(obj_node):
|
||
for restriction in right_node.findall(rt("restrictionByCondition")):
|
||
condition = restriction.find(rt("condition"))
|
||
if condition is not None and condition.text and re.search(
|
||
"#" + re.escape(identifier) + r"\s*\(", condition.text):
|
||
users.append(f"{node_text(obj_node, 'name')}.{node_text(right_node, 'name')}")
|
||
self.remove_child(node)
|
||
self.remove_count += 1
|
||
self.rights_dirty = True
|
||
self.note(f" {spec['Name']}: шаблон удалён")
|
||
if users:
|
||
print(f"[role-edit] На шаблон '{identifier}' ещё ссылаются: {', '.join(users)}", file=sys.stderr)
|
||
|
||
# --- Глобальные флаги ---
|
||
|
||
def apply_modify_property(self, spec):
|
||
node = self.root.find(rt(spec["Name"]))
|
||
if node is None:
|
||
self.note(f"[WARN] {spec['Name']}: свойства нет в файле роли, пропуск")
|
||
return
|
||
if (node.text or "") == spec["Value"]:
|
||
self.note(f" {spec['Name']}: уже {spec['Value']}, изменений нет")
|
||
return
|
||
node.text = spec["Value"]
|
||
# Умолчания решают, какие записи вообще пишутся, — следующие операции должны видеть новое значение.
|
||
if spec["Name"] == "setForNewObjects":
|
||
self.role_sfno = spec["Value"]
|
||
if spec["Name"] == "setForAttributesByDefault":
|
||
self.role_sfab = spec["Value"]
|
||
self.modify_count += 1
|
||
self.rights_dirty = True
|
||
self.note(f" {spec['Name']} = {spec['Value']}")
|
||
# Измерено: при setForNewObjects=true платформа перестаёт хранить права, совпадающие с
|
||
# автоматически выдаваемыми, и переписывает файл роли целиком.
|
||
if spec["Name"] == "setForNewObjects" and spec["Value"] == "true":
|
||
print("[role-edit] setForNewObjects=true: платформа пересчитает хранимые права роли "
|
||
"при первой же загрузке — часть явных записей исчезнет", file=sys.stderr)
|
||
|
||
# --- Метаданные роли ---
|
||
|
||
def edit_role_metadata(self, field, text):
|
||
path = self.paths["RoleXmlPath"]
|
||
if not os.path.isfile(path):
|
||
add_validation_error(f"Файл метаданных роли не найден: {path}")
|
||
return
|
||
if self.meta_tree is None:
|
||
self.meta_tree = etree.parse(path, etree.XMLParser(remove_blank_text=False))
|
||
props = self.meta_tree.getroot().find("{%s}Role/{%s}Properties" % (MD_OBJECT_NS, MD_OBJECT_NS))
|
||
if props is None:
|
||
add_validation_error(f"В метаданных роли нет блока <Properties>: {path}")
|
||
return
|
||
node = props.find("{%s}%s" % (MD_OBJECT_NS, field))
|
||
indent = get_child_indent(props)
|
||
if field == "Synonym":
|
||
if text:
|
||
xml = (f"<Synonym>\r\n{indent}\t<v8:item>\r\n{indent}\t\t<v8:lang>ru</v8:lang>\r\n"
|
||
f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>\r\n"
|
||
f"{indent}\t</v8:item>\r\n{indent}</Synonym>")
|
||
else:
|
||
xml = "<Synonym/>"
|
||
else:
|
||
xml = f"<Comment>{esc_xml_text(text)}</Comment>" if text else "<Comment/>"
|
||
wrapper = f'<_W xmlns="{MD_OBJECT_NS}" xmlns:v8="{V8_NS}">{xml}</_W>'
|
||
new_el = etree.fromstring(wrapper.encode("utf-8"), etree.XMLParser(remove_blank_text=False))[0]
|
||
if node is not None:
|
||
new_el.tail = node.tail
|
||
props.replace(node, new_el)
|
||
else:
|
||
self.insert_child(props, new_el, None, indent)
|
||
self.meta_dirty = True
|
||
self.modify_count += 1
|
||
self.note(f" {field} обновлён в метаданных роли")
|
||
|
||
|
||
def save_tree(tree, path):
|
||
style = _detect_xml_style(path)
|
||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8", standalone=None)
|
||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||
with open(path, "wb") as f:
|
||
if style is None or style["bom"]:
|
||
f.write(b"\xef\xbb\xbf")
|
||
f.write(xml_bytes)
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(description="Edit existing 1C role rights in place", allow_abbrev=False)
|
||
parser.add_argument("-RolePath", "-Path", "-RightsPath", required=True)
|
||
parser.add_argument("-DefinitionFile", default=None)
|
||
parser.add_argument("-Operation", default=None, choices=[
|
||
"add-rights", "set-rights", "remove-rights", "deny-rights", "set-rls", "remove-rls",
|
||
"add-template", "set-template", "remove-template", "modify-property", "set-synonym", "set-comment"])
|
||
parser.add_argument("-Value", default=None)
|
||
parser.add_argument("-NoValidate", action="store_true")
|
||
args = ci_parse_args(parser)
|
||
|
||
paths = resolve_role_paths(args.RolePath)
|
||
|
||
value = args.Value
|
||
|
||
if args.DefinitionFile and args.Operation:
|
||
print("[role-edit] Укажите либо -DefinitionFile, либо -Operation, но не оба сразу", file=sys.stderr)
|
||
sys.exit(1)
|
||
if not args.DefinitionFile and not args.Operation:
|
||
print("[role-edit] Укажите -Operation с -Value или -DefinitionFile", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
target_for_guard = paths["RoleXmlPath"] if os.path.isfile(paths["RoleXmlPath"]) else paths["RightsPath"]
|
||
assert_edit_allowed(target_for_guard, "editable")
|
||
|
||
text_base_dir = (os.path.dirname(os.path.abspath(args.DefinitionFile)) if args.DefinitionFile
|
||
else os.path.dirname(paths["RightsPath"]))
|
||
ed = Editor(paths, text_base_dir)
|
||
|
||
operations = []
|
||
if args.DefinitionFile:
|
||
data = ci_json(parse_json_input(read_json_file(args.DefinitionFile),
|
||
f"-DefinitionFile '{args.DefinitionFile}'",
|
||
"a JSON object or array of operations"))
|
||
items = data if isinstance(data, list) else [data]
|
||
for item in items:
|
||
op_name = str(item.get("operation") or item.get("op") or "")
|
||
op_value = str(item.get("value") or "")
|
||
operations.append((op_name, op_value))
|
||
else:
|
||
operations.append((args.Operation, value or ""))
|
||
|
||
pending = []
|
||
for op_name, op_value in operations:
|
||
key = op_name.strip().lower()
|
||
if key in ("add-rights", "set-rights", "remove-rights", "deny-rights"):
|
||
for item in ed.parse_batch(op_value):
|
||
spec = ed.parse_rights_spec(item, allow_no_rights=(key == "remove-rights"))
|
||
if not spec:
|
||
continue
|
||
if key == "add-rights":
|
||
rights = [{"Name": r, "Value": "true", "Condition": None} for r in spec["Rights"]]
|
||
for expanded in expand_service_entry({"Name": spec["Name"], "Rights": rights},
|
||
paths["ConfigRoot"], paths["RoleName"]):
|
||
pending.append((key, {"Name": expanded["Name"],
|
||
"Rights": [r["Name"] for r in expanded["Rights"]]}))
|
||
else:
|
||
pending.append((key, spec))
|
||
elif key in ("set-rls", "remove-rls"):
|
||
for item in ed.parse_batch(op_value):
|
||
spec = ed.parse_rls_address(item, condition_required=(key == "set-rls"))
|
||
if spec:
|
||
pending.append((key, spec))
|
||
elif key in ("add-template", "set-template", "remove-template"):
|
||
for item in ed.parse_batch(op_value):
|
||
spec = ed.parse_template_spec(item, name_only=(key == "remove-template"))
|
||
if spec:
|
||
pending.append((key, spec))
|
||
elif key == "modify-property":
|
||
allowed = ["setForNewObjects", "setForAttributesByDefault", "independentRightsOfChildObjects"]
|
||
for item in ed.parse_batch(op_value):
|
||
eq = item.find("=")
|
||
if eq < 1:
|
||
add_validation_error(f"{item} : ожидается 'свойство=true' или 'свойство=false'")
|
||
continue
|
||
name = item[:eq].strip()
|
||
val = item[eq + 1:].strip().lower()
|
||
canonical = next((a for a in allowed if a.lower() == name.lower()), None)
|
||
if not canonical:
|
||
add_validation_error(f"{name} : неизвестное свойство роли, допустимы {', '.join(allowed)}")
|
||
continue
|
||
if val not in ("true", "false"):
|
||
add_validation_error(f"{item} : значение должно быть true или false")
|
||
continue
|
||
pending.append((key, {"Name": canonical, "Value": val}))
|
||
elif key == "set-synonym":
|
||
pending.append((key, {"Field": "Synonym", "Text": resolve_text_from_file(op_value, ed.text_base_dir)}))
|
||
elif key == "set-comment":
|
||
pending.append((key, {"Field": "Comment", "Text": resolve_text_from_file(op_value, ed.text_base_dir)}))
|
||
else:
|
||
add_validation_error(f"Неизвестная операция: {op_name}")
|
||
|
||
# Отказ до записи: правка роли — это несколько узлов сразу, и наполовину применённая правка
|
||
# хуже неприменённой. Печатаем все причины разом.
|
||
def refuse_if_errors():
|
||
if VALIDATION_ERRORS:
|
||
print(f"[role-edit] Правка не применена: {len(VALIDATION_ERRORS)} ошибок во входе.", file=sys.stderr)
|
||
for err in VALIDATION_ERRORS:
|
||
print(f" ERROR: {err}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
refuse_if_errors()
|
||
|
||
handlers = {
|
||
"add-rights": ed.apply_add_rights,
|
||
"set-rights": ed.apply_set_rights,
|
||
"deny-rights": ed.apply_deny_rights,
|
||
"remove-rights": ed.apply_remove_rights,
|
||
"add-template": ed.apply_add_template,
|
||
"set-template": lambda s: ed.apply_add_template(s, allow_replace=True),
|
||
"set-rls": ed.apply_set_rls,
|
||
"remove-rls": ed.apply_remove_rls,
|
||
"remove-template": ed.apply_remove_template,
|
||
"modify-property": ed.apply_modify_property,
|
||
"set-synonym": lambda s: ed.edit_role_metadata(s["Field"], s["Text"]),
|
||
"set-comment": lambda s: ed.edit_role_metadata(s["Field"], s["Text"]),
|
||
}
|
||
# Операции применяются в том порядке, в котором их перечислили.
|
||
for op_key, spec in pending:
|
||
handlers[op_key](spec)
|
||
|
||
# Ошибка могла всплыть и на применении (RLS без права) — файл в этом случае не трогаем.
|
||
refuse_if_errors()
|
||
|
||
if ed.rights_dirty:
|
||
save_tree(ed.tree, paths["RightsPath"])
|
||
if ed.meta_dirty:
|
||
save_tree(ed.meta_tree, paths["RoleXmlPath"])
|
||
|
||
print(f"[OK] Роль '{paths['RoleName']}' обновлена")
|
||
print(f" Rights: {paths['RightsPath']}")
|
||
for note in ed.notes:
|
||
print(note)
|
||
print(f" Added: {ed.add_count}, Removed: {ed.remove_count}, Modified: {ed.modify_count}")
|
||
if ed.dropped_by_default:
|
||
print("[role-edit] Не записаны права, совпадающие с умолчанием роли "
|
||
f"(платформа их не хранит): {', '.join(ed.dropped_by_default)}", file=sys.stderr)
|
||
print(" Запрет хранится у реквизитов и табличных частей (они наследуют права объекта) "
|
||
"либо в роли с setForNewObjects=true; выдача прав — наоборот.", file=sys.stderr)
|
||
|
||
if not args.NoValidate:
|
||
validate_script = os.path.normpath(os.path.join(
|
||
os.path.dirname(__file__), "..", "..", "role-validate", "scripts", "role-validate.py"))
|
||
if os.path.isfile(validate_script):
|
||
print()
|
||
print("--- Running role-validate ---")
|
||
subprocess.run([sys.executable, validate_script, "-RightsPath", paths["RightsPath"]])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|