mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-14 15:33:20 +03:00
625 lines
26 KiB
Python
625 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
# role-validate v1.3 — Validate 1C role Rights.xml structure
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
"""Validates role Rights.xml: root element, global flags, objects, rights, RLS, templates."""
|
||
import sys, os, argparse, re
|
||
from lxml import etree
|
||
|
||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||
# регистр не различают, в argparse совпадение точное.
|
||
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)
|
||
|
||
|
||
GUID_PATTERN = re.compile(
|
||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
RIGHTS_NS = 'http://v8.1c.ru/8.2/roles'
|
||
|
||
# --- 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', 'View'],
|
||
'Constant': [
|
||
'Read', 'Update', 'View', 'Edit',
|
||
'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
|
||
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
|
||
'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
|
||
],
|
||
'ChartOfAccounts': [
|
||
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
|
||
'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
|
||
'InteractiveDelete',
|
||
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
|
||
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
|
||
'ReadDataHistory', 'ReadDataHistoryOfMissingData',
|
||
'UpdateDataHistory', 'UpdateDataHistoryOfMissingData',
|
||
'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
|
||
],
|
||
'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',
|
||
'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
|
||
'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
|
||
],
|
||
'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', 'InteractiveActivate', 'InteractiveStart',
|
||
],
|
||
'Task': [
|
||
'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
|
||
'Execute', 'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
|
||
'InteractiveDelete', 'InteractiveActivate', 'InteractiveExecute',
|
||
],
|
||
'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',
|
||
}
|
||
|
||
# Один и тот же вид под разными родителями имеет разный набор: измерение регистра —
|
||
# 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',
|
||
]
|
||
|
||
|
||
def get_object_type(name):
|
||
dot_idx = name.find('.')
|
||
if dot_idx < 0:
|
||
return name
|
||
return name[:dot_idx]
|
||
|
||
|
||
def is_nested_object(name):
|
||
return name.count('.') >= 2
|
||
|
||
|
||
def get_nested_kind(name):
|
||
"""Вид вложенности — предпоследний сегмент: путь бывает и восьмисегментным
|
||
(ExternalDataSource.И.Cube.К.DimensionTable.Т.Field.П), считать от конца."""
|
||
parts = 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)
|
||
|
||
|
||
def find_similar(needle, haystack):
|
||
result = []
|
||
needle_lower = needle.lower()
|
||
for h in haystack:
|
||
h_lower = h.lower()
|
||
if needle_lower in h_lower or h_lower in needle_lower:
|
||
result.append(h)
|
||
if len(result) >= 3:
|
||
break
|
||
return result
|
||
|
||
|
||
def format_type_error(obj_name, object_type):
|
||
"""Запрещённый тип и незнакомый тип — разные диагнозы: первый документирован,
|
||
второй скорее опечатка или пробел в таблице."""
|
||
if object_type in NO_RIGHTS_TYPES:
|
||
return f"{obj_name}: тип '{object_type}' не имеет прав в роли — блок нужно удалить"
|
||
similar = find_similar(object_type, list(KNOWN_RIGHTS.keys()))
|
||
sug = f" Возможно: {', '.join(similar)}?" if similar else ''
|
||
return f"{obj_name}: неизвестный тип объекта '{object_type}'.{sug}"
|
||
|
||
|
||
def get_child_text(parent, local_name, ns):
|
||
"""Get text of first child element with given local name in namespace."""
|
||
for child in parent:
|
||
if not isinstance(child.tag, str):
|
||
continue
|
||
if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
|
||
return child.text or ''
|
||
return None
|
||
|
||
|
||
def get_child_el(parent, local_name, ns):
|
||
"""Get first child element with given local name in namespace."""
|
||
for child in parent:
|
||
if not isinstance(child.tag, str):
|
||
continue
|
||
if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
|
||
return child
|
||
return None
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(
|
||
description='Validate 1C role Rights.xml structure', allow_abbrev=False
|
||
)
|
||
parser.add_argument('-RightsPath', '-Path', dest='RightsPath', required=True)
|
||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||
parser.add_argument('-Detailed', dest='Detailed', action='store_true')
|
||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||
args = ci_parse_args(parser)
|
||
|
||
rights_path = args.RightsPath
|
||
out_file = args.OutFile
|
||
|
||
if not os.path.isabs(rights_path):
|
||
rights_path = os.path.join(os.getcwd(), rights_path)
|
||
|
||
# A: Directory → Ext/Rights.xml
|
||
if os.path.isdir(rights_path):
|
||
rights_path = os.path.join(rights_path, 'Ext', 'Rights.xml')
|
||
# B1: Missing Ext/
|
||
if not os.path.exists(rights_path):
|
||
fn = os.path.basename(rights_path)
|
||
if fn == 'Rights.xml':
|
||
c = os.path.join(os.path.dirname(rights_path), 'Ext', fn)
|
||
if os.path.exists(c):
|
||
rights_path = c
|
||
|
||
resolved_path = os.path.abspath(rights_path)
|
||
|
||
# Auto-detect metadata: Roles/Name/Ext/Rights.xml → Roles/Name.xml
|
||
ext_dir = os.path.dirname(resolved_path)
|
||
role_dir = os.path.dirname(ext_dir)
|
||
roles_dir = os.path.dirname(role_dir)
|
||
role_dir_name = os.path.basename(role_dir)
|
||
metadata_path = os.path.join(roles_dir, f'{role_dir_name}.xml')
|
||
|
||
# --- Output helpers ---
|
||
lines = []
|
||
errors = 0
|
||
warnings = 0
|
||
ok_count = 0
|
||
stopped = False
|
||
|
||
def report_ok(msg):
|
||
nonlocal ok_count
|
||
ok_count += 1
|
||
if args.Detailed:
|
||
lines.append(f'[OK] {msg}')
|
||
|
||
def report_warn(msg):
|
||
nonlocal warnings
|
||
warnings += 1
|
||
lines.append(f'[WARN] {msg}')
|
||
|
||
def report_error(msg):
|
||
nonlocal errors, stopped
|
||
errors += 1
|
||
lines.append(f'[ERROR] {msg}')
|
||
if errors >= args.MaxErrors:
|
||
stopped = True
|
||
|
||
# --- 3. Validate Rights.xml ---
|
||
|
||
def write_output(text):
|
||
if out_file:
|
||
out_path = out_file if os.path.isabs(out_file) else os.path.join(os.getcwd(), out_file)
|
||
out_dir = os.path.dirname(out_path)
|
||
if out_dir and not os.path.exists(out_dir):
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
with open(out_path, 'w', encoding='utf-8-sig', newline='') as f:
|
||
f.write(text)
|
||
print(f'Written to: {out_path}')
|
||
else:
|
||
print(text)
|
||
|
||
if not os.path.exists(rights_path):
|
||
report_error(f'File not found: {rights_path}')
|
||
result = '\n'.join(lines)
|
||
write_output(result)
|
||
sys.exit(1)
|
||
|
||
# 3a. Parse XML
|
||
xml_doc = None
|
||
try:
|
||
xml_parser = etree.XMLParser(remove_blank_text=False)
|
||
xml_doc = etree.parse(rights_path, xml_parser)
|
||
report_ok('XML well-formed')
|
||
except etree.XMLSyntaxError as e:
|
||
report_error(f'XML parse error: {e}')
|
||
result = '\n'.join(lines)
|
||
write_output(result)
|
||
sys.exit(1)
|
||
|
||
root = xml_doc.getroot()
|
||
root_local = etree.QName(root.tag).localname
|
||
root_ns = etree.QName(root.tag).namespace or ''
|
||
|
||
# 3b. Check root element
|
||
if root_local != 'Rights':
|
||
report_error(f"Root element is '{root_local}', expected 'Rights'")
|
||
elif root_ns != RIGHTS_NS:
|
||
report_warn(f"Namespace is '{root_ns}', expected '{RIGHTS_NS}'")
|
||
else:
|
||
report_ok('Root element: <Rights> with correct namespace')
|
||
|
||
# 3c. Global flags
|
||
flag_names = ['setForNewObjects', 'setForAttributesByDefault', 'independentRightsOfChildObjects']
|
||
flags_found = 0
|
||
for fn in flag_names:
|
||
nodes = root.findall(f'{{{RIGHTS_NS}}}{fn}')
|
||
if len(nodes) > 0:
|
||
val = nodes[0].text or ''
|
||
if val not in ('true', 'false'):
|
||
report_warn(f"{fn} = '{val}' (expected 'true' or 'false')")
|
||
flags_found += 1
|
||
else:
|
||
report_warn(f'Missing global flag: {fn}')
|
||
if flags_found == 3:
|
||
report_ok('3 global flags present')
|
||
|
||
# 3d. Objects
|
||
objects = root.findall(f'{{{RIGHTS_NS}}}object')
|
||
obj_count = len(objects)
|
||
right_count = 0
|
||
rls_count = 0
|
||
|
||
for obj in objects:
|
||
obj_name = ''
|
||
for child in obj:
|
||
if not isinstance(child.tag, str):
|
||
continue
|
||
if etree.QName(child.tag).localname == 'name' and etree.QName(child.tag).namespace == RIGHTS_NS:
|
||
obj_name = child.text or ''
|
||
break
|
||
|
||
if not obj_name:
|
||
report_error('Object without <name>')
|
||
continue
|
||
|
||
object_type = get_object_type(obj_name)
|
||
is_nested = is_nested_object(obj_name)
|
||
|
||
# Тип проверяется ВСЕГДА, включая вложенные пути: раньше `Enum.Х.Attribute.Y`
|
||
# не проверялся вообще — ветка «unknown» стояла под `not is_nested`.
|
||
nested_kind = get_nested_kind(obj_name) if is_nested else None
|
||
type_known = object_type in KNOWN_RIGHTS
|
||
if not type_known:
|
||
report_error(format_type_error(obj_name, object_type))
|
||
elif is_nested and nested_kind in KIND_OWNERS and object_type != KIND_OWNERS[nested_kind]:
|
||
report_error(f"{obj_name}: вид '{nested_kind}' бывает только у {KIND_OWNERS[nested_kind]}")
|
||
elif is_nested and get_nested_rights(object_type, nested_kind) is None:
|
||
report_error(f"{obj_name}: неизвестный вид вложенности '{nested_kind}'")
|
||
|
||
# Check rights
|
||
for child in obj:
|
||
if not isinstance(child.tag, str):
|
||
continue
|
||
if etree.QName(child.tag).localname != 'right' or etree.QName(child.tag).namespace != RIGHTS_NS:
|
||
continue
|
||
|
||
r_name = ''
|
||
r_value = ''
|
||
has_rls = False
|
||
|
||
for rc in child:
|
||
if not isinstance(rc.tag, str):
|
||
continue
|
||
rc_local = etree.QName(rc.tag).localname
|
||
rc_ns = etree.QName(rc.tag).namespace
|
||
if rc_ns != RIGHTS_NS:
|
||
continue
|
||
|
||
if rc_local == 'name':
|
||
r_name = rc.text or ''
|
||
elif rc_local == 'value':
|
||
r_value = rc.text or ''
|
||
elif rc_local == 'restrictionByCondition':
|
||
has_rls = True
|
||
rls_count += 1
|
||
# Check condition not empty
|
||
cond_node = get_child_el(rc, 'condition', RIGHTS_NS)
|
||
if cond_node is None or not (cond_node.text or ''):
|
||
report_warn(f"{obj_name}: RLS condition for '{r_name}' is empty")
|
||
|
||
if not r_name:
|
||
report_error(f'{obj_name}: <right> without <name>')
|
||
continue
|
||
|
||
if r_value not in ('true', 'false'):
|
||
report_error(f"{obj_name}: right '{r_name}' has invalid value '{r_value}'")
|
||
continue
|
||
|
||
right_count += 1
|
||
|
||
# Validate right name. Тип уже отвергнут выше — второй раз про него не пишем.
|
||
if not type_known:
|
||
pass
|
||
elif is_nested:
|
||
valid_nested = get_nested_rights(object_type, nested_kind)
|
||
if valid_nested is not None and r_name not in valid_nested:
|
||
report_error(f"{obj_name}: право '{r_name}' недопустимо для вида '{nested_kind}' (допустимо: {', '.join(valid_nested)})")
|
||
else:
|
||
valid_rights = KNOWN_RIGHTS[object_type]
|
||
if r_name not in valid_rights:
|
||
similar = find_similar(r_name, valid_rights)
|
||
sug_str = f" Возможно: {', '.join(similar)}?" if similar else ''
|
||
report_error(f"{obj_name}: право '{r_name}' не существует у типа '{object_type}'.{sug_str}")
|
||
|
||
report_ok(f'{obj_count} objects, {right_count} rights')
|
||
if rls_count > 0:
|
||
report_ok(f'{rls_count} RLS restrictions')
|
||
|
||
# 3e. Templates
|
||
templates = root.findall(f'{{{RIGHTS_NS}}}restrictionTemplate')
|
||
if len(templates) > 0:
|
||
tpl_names = []
|
||
for tpl in templates:
|
||
t_name = ''
|
||
t_cond = ''
|
||
for child in tpl:
|
||
if not isinstance(child.tag, str):
|
||
continue
|
||
local = etree.QName(child.tag).localname
|
||
ns = etree.QName(child.tag).namespace
|
||
if ns != RIGHTS_NS:
|
||
continue
|
||
if local == 'name':
|
||
t_name = child.text or ''
|
||
elif local == 'condition':
|
||
t_cond = child.text or ''
|
||
if not t_name:
|
||
report_warn('Restriction template without <name>')
|
||
else:
|
||
paren_idx = t_name.find('(')
|
||
short_name = t_name[:paren_idx] if paren_idx > 0 else t_name
|
||
tpl_names.append(short_name)
|
||
if not t_cond:
|
||
report_warn(f"Template '{t_name}': empty <condition>")
|
||
report_ok(f'{len(templates)} templates: {", ".join(tpl_names)}')
|
||
|
||
# --- 4. Validate metadata (optional) ---
|
||
inferred_role_name = ''
|
||
if os.path.isfile(metadata_path):
|
||
lines.append('')
|
||
|
||
try:
|
||
meta_parser = etree.XMLParser(remove_blank_text=False)
|
||
meta_xml = etree.parse(metadata_path, meta_parser)
|
||
meta_root = meta_xml.getroot()
|
||
# Find <Role> element anywhere
|
||
role_node = None
|
||
for el in meta_root.iter():
|
||
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Role':
|
||
role_node = el
|
||
break
|
||
|
||
if role_node is None:
|
||
report_error('Metadata: <Role> element not found')
|
||
else:
|
||
uuid_val = role_node.get('uuid', '')
|
||
if GUID_PATTERN.match(uuid_val):
|
||
report_ok(f'Metadata: UUID valid ({uuid_val})')
|
||
else:
|
||
report_error(f"Metadata: invalid UUID format '{uuid_val}'")
|
||
|
||
# Find Name
|
||
name_node = None
|
||
for el in role_node.iter():
|
||
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Name':
|
||
name_node = el
|
||
break
|
||
|
||
if name_node is not None and name_node.text:
|
||
report_ok(f'Metadata: Name = {name_node.text}')
|
||
inferred_role_name = name_node.text
|
||
else:
|
||
report_error('Metadata: <Name> is empty or missing')
|
||
|
||
# Find Synonym
|
||
syn_node = None
|
||
for el in role_node.iter():
|
||
if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Synonym':
|
||
syn_node = el
|
||
break
|
||
|
||
if syn_node is not None and len(syn_node) > 0:
|
||
report_ok('Metadata: Synonym present')
|
||
else:
|
||
report_warn('Metadata: <Synonym> is empty')
|
||
except etree.XMLSyntaxError as e:
|
||
report_error(f'Metadata XML parse error: {e}')
|
||
|
||
# --- 5. Check registration in Configuration.xml ---
|
||
config_dir = os.path.dirname(roles_dir) # config root
|
||
config_xml_path = os.path.join(config_dir, 'Configuration.xml')
|
||
|
||
if not inferred_role_name:
|
||
inferred_role_name = os.path.basename(role_dir)
|
||
|
||
if os.path.exists(config_xml_path):
|
||
lines.append('')
|
||
try:
|
||
cfg_parser = etree.XMLParser(remove_blank_text=False)
|
||
cfg_xml = etree.parse(config_xml_path, cfg_parser)
|
||
cfg_ns = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
|
||
child_obj = cfg_xml.getroot().find('.//md:Configuration/md:ChildObjects', cfg_ns)
|
||
if child_obj is not None:
|
||
role_nodes = child_obj.findall('md:Role', cfg_ns)
|
||
found = False
|
||
for rn in role_nodes:
|
||
if (rn.text or '') == inferred_role_name:
|
||
found = True
|
||
break
|
||
if found:
|
||
report_ok(f'Configuration.xml: <Role>{inferred_role_name}</Role> registered')
|
||
else:
|
||
report_warn(f'Configuration.xml: <Role>{inferred_role_name}</Role> NOT found in ChildObjects')
|
||
except etree.XMLSyntaxError as e:
|
||
report_warn(f'Configuration.xml: parse error \u2014 {e}')
|
||
|
||
# --- 6. Summary ---
|
||
|
||
# Insert header at position 0
|
||
lines.insert(0, f'=== Validation: Role.{inferred_role_name} ===')
|
||
|
||
checks = ok_count + errors + warnings
|
||
if errors == 0 and warnings == 0 and not args.Detailed:
|
||
result = f'=== Validation OK: Role.{inferred_role_name} ({checks} checks) ==='
|
||
else:
|
||
lines.append('')
|
||
lines.append(f'=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===')
|
||
result = '\n'.join(lines)
|
||
write_output(result)
|
||
sys.exit(1 if errors > 0 else 0)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|