Path:
strictdoc/core/transforms/update_requirement.py
Lines:
585
Non-empty lines:
533
Non-empty lines covered with requirements:
533 / 533 (100.0%)
Functions:
10
Functions covered by requirements:
10 / 10 (100.0%)
1
"""2
@relation(SDOC-SRS-55, scope=file)3
"""4
5
import datetime
6
from copy import copy
7
from dataclasses import dataclass
8
from typing import Dict, List, Optional, Set, Tuple, Union
9
10
from textx import TextXSyntaxError
11
12
from strictdoc.backend.markdown.markdown_to_html_fragment_writer import (
13
MarkdownToHtmlFragmentWriter,
14
)15
from strictdoc.backend.rst.rst_to_html_fragment_writer import (
16
RstToHtmlFragmentWriter,
17
)18
from strictdoc.backend.sdoc.constants import SDocMarkup
19
from strictdoc.backend.sdoc.document_reference import DocumentReference
20
from strictdoc.backend.sdoc.error_handling import get_textx_syntax_error_message
21
from strictdoc.backend.sdoc.free_text_reader import SDFreeTextReader
22
from strictdoc.backend.sdoc.models.anchor import Anchor
23
from strictdoc.backend.sdoc.models.document import SDocDocument
24
from strictdoc.backend.sdoc.models.free_text import FreeTextContainer
25
from strictdoc.backend.sdoc.models.grammar_element import GrammarElement
26
from strictdoc.backend.sdoc.models.inline_link import InlineLink
27
from strictdoc.backend.sdoc.models.model import (
28
RequirementFieldName,
29
SDocDocumentIF,
30
SDocNodeIF,
31
)32
from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField
33
from strictdoc.backend.sdoc.models.object_factory import SDocObjectFactory
34
from strictdoc.backend.sdoc.models.reference import (
35
Reference,
36
)37
from strictdoc.core.constants import (
38
GraphLinkType,
39
)40
from strictdoc.core.project_config import ProjectConfig
41
from strictdoc.core.traceability_index import (
42
TraceabilityIndex,
43
)44
from strictdoc.core.transforms.constants import NodeCreationOrder
45
from strictdoc.core.transforms.validation_error import (
46
SingleValidationError,
47
)48
from strictdoc.export.html.form_objects.requirement_form_object import (
49
RequirementFormField,
50
RequirementFormFieldType,
51
RequirementFormObject,
52
)53
from strictdoc.helpers.cast import assert_cast
54
from strictdoc.helpers.file_modification_time import set_file_modification_time
55
from strictdoc.helpers.mid import MID
56
57
58
@dataclass59
class CreateNodeInfo:
60
whereto: str
61
requirement_mid: str
62
reference_mid: str
63
64
65
@dataclass66
class UpdateNodeInfo:
67
node_to_update: SDocNode
68
69
70
class UpdateRequirementActionObject:
71
def __init__(self) -> None:
72
self.existing_references_uids: Set[Tuple[str, str, Optional[str]]] = (
73
set()
74
)75
self.reference_ids_to_remove: Set[Tuple[str, str, Optional[str]]] = (
76
set()
77
)78
self.removed_uid_parent_documents_to_update: Set[SDocDocument] = set()
79
# All requirements that have to be updated. This set includes80
# the requirement itself, all links it was linking to81
# (for deleted links) and all links it is linking to now82
# (including new links).83
self.this_document_requirements_to_update: Set[SDocNode] = set()
84
85
86
@dataclass87
class CreateOrUpdateNodeResult:
88
this_document_requirements_to_update: List[SDocNode]
89
90
91
class CreateOrUpdateNodeCommand:
92
def __init__(
93
self,
94
*,
95
form_object: RequirementFormObject,
96
node_info: Union[CreateNodeInfo, UpdateNodeInfo],
97
traceability_index: TraceabilityIndex,
98
project_config: ProjectConfig,
99
) -> None:
100
self.form_object: RequirementFormObject = form_object
101
self.node_info: Union[CreateNodeInfo, UpdateNodeInfo] = node_info
102
self.traceability_index: TraceabilityIndex = traceability_index
103
self.project_config: ProjectConfig = project_config
104
105
def perform(self) -> Optional[CreateOrUpdateNodeResult]:
106
form_object: RequirementFormObject = self.form_object
107
node_to_update_or_none: Optional[SDocNode] = (
108
self.node_info.node_to_update
109
if isinstance(self.node_info, UpdateNodeInfo)
110
else None
111
)112
113
traceability_index: TraceabilityIndex = self.traceability_index
114
115
if node_to_update_or_none is not None:
116
node_document = assert_cast(
117
node_to_update_or_none.get_document(), SDocDocument
118
)119
else:
120
node_document = assert_cast(
121
traceability_index.get_node_by_mid(
122
MID(form_object.document_mid)
123
),124
SDocDocument,
125
)126
127
map_form_to_requirement_fields: Dict[
128
RequirementFormField, Optional[FreeTextContainer]
129
] = {}
130
this_node_unique_anchors: Set[str] = set()
131
for field_bucket_ in form_object.fields.values():
132
field_: RequirementFormField
133
for field_ in field_bucket_:
134
if field_.field_type != RequirementFormFieldType.MULTILINE:
135
continue136
# Validate the field content using the writer that matches the137
# document's markup (RST by default, Markdown for Markdown138
# documents).139
markup = (
140
node_document.config.markup
141
if node_document is not None
142
else None
143
)144
if markup == SDocMarkup.MARKDOWN:
145
(146
parsed_html,
147
markup_error,
148
) = MarkdownToHtmlFragmentWriter.write_with_validation(
149
field_.field_value
150
)151
else:
152
(153
parsed_html,
154
markup_error,
155
) = RstToHtmlFragmentWriter(
156
project_config=self.project_config,
157
context_document=node_document,
158
).write_with_validation(field_.field_value)
159
if parsed_html is None:
160
assert markup_error is not None
161
form_object.add_error(field_.field_name, markup_error)
162
else:
163
try:
164
free_text_container: Optional[FreeTextContainer] = (
165
SDFreeTextReader.read(field_.field_value)
166
if len(field_.field_value) > 0
167
else None
168
)169
map_form_to_requirement_fields[field_] = (
170
free_text_container171
)172
if free_text_container is None:
173
continue174
anchors: List[Anchor] = []
175
for part in free_text_container.parts:
176
if isinstance(part, InlineLink):
177
linked_to_node = traceability_index.get_linkable_node_by_uid_weak(
178
part.link
179
)180
if linked_to_node is None:
181
form_object.add_error(
182
field_.field_name,
183
"A LINK points to a node that does "184
f"not exist: '{part.link}'.",
185
)186
elif isinstance(part, Anchor):
187
if part.value in this_node_unique_anchors:
188
form_object.add_error(
189
field_.field_name,
190
"The node fields contain duplicate anchor: "191
f"'{part.value}'.",
192
)193
break194
this_node_unique_anchors.add(part.value)
195
anchors.append(part)
196
else:
197
pass198
if anchors is not None:
199
try:
200
traceability_index.validate_node_against_anchors(
201
node=node_to_update_or_none,
202
new_anchors=anchors,
203
)204
except (
205
SingleValidationError206
) as anchors_validation_error:
207
form_object.add_error(
208
field_.field_name,
209
anchors_validation_error.args[0],
210
)211
except TextXSyntaxError as exception:
212
form_object.add_error(
213
field_.field_name,
214
get_textx_syntax_error_message(exception),
215
)216
217
if form_object.any_errors():
218
return None
219
220
requirement: SDocNode
221
document: SDocDocument
222
existing_uid: Optional[str]
223
existing_node_fields: List[SDocNodeField] = []
224
old_title_is_none: bool = False
225
if isinstance(self.node_info, UpdateNodeInfo):
226
requirement = self.node_info.node_to_update
227
document = assert_cast(requirement.get_document(), SDocDocument)
228
229
existing_uid = requirement.reserved_uid
230
old_title_is_none = requirement.reserved_title is None
231
232
existing_node_fields = list(requirement.enumerate_fields())
233
234
# Clearing all existing fields because they will be recreated from235
# scratch from the form data.236
requirement.ordered_fields_lookup.clear()
237
238
self.populate_node_fields_from_form_object(
239
requirement, form_object, map_form_to_requirement_fields
240
)241
242
# Updating Traceability Index: UID.243
traceability_index.update_requirement_uid(
244
requirement=requirement, old_uid=existing_uid
245
)246
else:
247
reference_node: Union[SDocDocument, SDocNode] = (
248
traceability_index.get_node_by_mid(
249
MID(self.node_info.reference_mid)
250
)251
)252
document = traceability_index.get_node_by_mid(
253
MID(form_object.document_mid)
254
)255
parent: Union[SDocDocumentIF, SDocNodeIF]
256
if self.node_info.whereto == NodeCreationOrder.CHILD:
257
parent = reference_node
258
insert_to_idx = len(parent.section_contents)
259
elif self.node_info.whereto == NodeCreationOrder.BEFORE:
260
# Be aware of an edge case besides all normal cases:261
# A reference node can be a root node of an included document.262
if not isinstance(reference_node, SDocDocument):
263
parent = reference_node.parent
264
insert_to_idx = parent.section_contents.index(
265
reference_node266
)267
else:
268
assert (
269
reference_node.ng_including_document_from_file
270
is not None
271
)272
parent = (
273
reference_node.ng_including_document_from_file.parent
274
)275
assert (
276
reference_node.ng_including_document_from_file
277
is not None
278
)279
insert_to_idx = parent.section_contents.index(
280
reference_node.ng_including_document_from_file
281
)282
elif self.node_info.whereto == NodeCreationOrder.AFTER:
283
if isinstance(reference_node, SDocDocument):
284
assert reference_node.document_is_included()
285
assert reference_node.ng_including_document_from_file
286
parent = (
287
reference_node.ng_including_document_from_file.parent
288
)289
insert_to_idx = (
290
parent.section_contents.index(
291
reference_node.ng_including_document_from_file
292
)293
+ 1
294
)295
else:
296
parent = reference_node.parent
297
insert_to_idx = (
298
parent.section_contents.index(reference_node) + 1
299
)300
else:
301
raise NotImplementedError
302
303
# Reset the 'needs generation' flag on all documents.304
for document_ in traceability_index.document_tree.document_list:
305
assert document_.meta is not None
306
set_file_modification_time(
307
document_.meta.input_doc_full_path,
308
datetime.datetime.today(),
309
)310
311
# FIXME: It is better to have a general create_node method because312
# we are dealing with arbitrary nodes, not only Requirement.313
requirement = SDocObjectFactory.create_requirement(
314
parent=parent, node_type=form_object.element_type
315
)316
requirement.reserved_mid = MID(form_object.requirement_mid)
317
assert document.grammar is not None
318
grammar_element: GrammarElement = document.grammar.elements_by_type[
319
form_object.element_type
320
]321
if (
322
document.config.enable_mid
323
or "MID" in grammar_element.fields_map
324
):325
requirement.mid_permanent = True
326
327
requirement.is_composite = (
328
grammar_element.property_is_composite is True
329
)330
requirement.ng_document_reference = DocumentReference()
331
requirement.ng_document_reference.set_document(document)
332
requirement.ng_including_document_reference = (
333
document.ng_including_document_reference
334
)335
336
parent.section_contents.insert(insert_to_idx, requirement)
337
338
self.populate_node_fields_from_form_object(
339
requirement, form_object, map_form_to_requirement_fields
340
)341
traceability_index.create_requirement(requirement=requirement)
342
343
# Recalculate ng_resolved_custom_level based on new title state.344
# This mirrors the logic in processor.py which sets it during parsing.345
# When a title is added/removed via the form, the value must be updated346
# to keep level numbering consistent.347
if (
348
RequirementFieldName.LEVEL not in requirement.ordered_fields_lookup
349
and document.config.auto_levels
350
):351
if (
352
requirement.reserved_title is None
353
or not document.config.is_requirement_in_toc()
354
):355
requirement.ng_resolved_custom_level = "None"
356
else:
357
requirement.ng_resolved_custom_level = None
358
359
action_object = UpdateRequirementActionObject()
360
action_object.existing_references_uids.update(
361
requirement.get_requirement_reference_uids()
362
)363
action_object.reference_ids_to_remove = copy(
364
action_object.existing_references_uids
365
)366
action_object.this_document_requirements_to_update = {requirement}
367
368
references: List[Reference] = form_object.get_requirement_relations(
369
requirement370
)371
if len(references) > 0:
372
requirement.relations = references
373
else:
374
requirement.relations = []
375
376
for document_ in traceability_index.document_tree.document_list:
377
assert document_.meta is not None
378
set_file_modification_time(
379
document_.meta.input_doc_full_path, datetime.datetime.today()
380
)381
382
# Updating Traceability Index: Links.383
for reference_field in form_object.reference_fields:
384
ref_uid = reference_field.field_value
385
ref_role: Optional[str] = (
386
reference_field.field_role
387
if reference_field.field_role is not None
388
and len(reference_field.field_role) > 0
389
else None
390
)391
# If a link is in the form, we don't want to remove it.392
if (
393
reference_field.field_type,
394
ref_uid,
395
ref_role,
396
) in action_object.reference_ids_to_remove:
397
action_object.reference_ids_to_remove.remove(
398
(reference_field.field_type, ref_uid, ref_role)
399
)400
# If a link is already in the requirement and traceability index,401
# there is nothing to do.402
if (
403
reference_field.field_type,
404
ref_uid,
405
ref_role,
406
) in action_object.existing_references_uids:
407
continue408
if reference_field.field_type == "Parent":
409
traceability_index.update_requirement_parent_uid(
410
requirement=requirement,
411
parent_uid=ref_uid,
412
role=reference_field.field_role
413
if len(reference_field.field_role) > 0
414
else None,
415
)416
elif reference_field.field_type == "Child":
417
traceability_index.update_requirement_child_uid(
418
requirement=requirement,
419
child_uid=ref_uid,
420
role=reference_field.field_role
421
if len(reference_field.field_role) > 0
422
else None,
423
)424
elif reference_field.field_type == "File":
425
pass426
else:
427
raise AssertionError(f"Must not reach here: {reference_field}")
428
429
# Calculate which documents and requirements have to be regenerated.430
for (
431
_,
432
reference_id_to_remove,
433
_,
434
) in action_object.reference_ids_to_remove:
435
removed_uid_parent_requirement: SDocNode = (
436
traceability_index.graph_database.get_link_value(
437
link_type=GraphLinkType.UID_TO_NODE,
438
lhs_node=reference_id_to_remove,
439
)440
)441
removed_uid_parent_requirement_document = assert_cast(
442
removed_uid_parent_requirement.get_document(), SDocDocument
443
)444
action_object.removed_uid_parent_documents_to_update.add(
445
removed_uid_parent_requirement_document446
)447
# If a link was pointing towards a parent requirement in this448
# document, we will have to re-render it now.449
if removed_uid_parent_requirement_document == document:
450
action_object.this_document_requirements_to_update.add(
451
removed_uid_parent_requirement452
)453
454
for (
455
relation_type_,
456
reference_id_to_remove,
457
reference_id_to_remove_role,
458
) in action_object.reference_ids_to_remove:
459
if relation_type_ == "Parent":
460
traceability_index.remove_requirement_parent_uid(
461
requirement=requirement,
462
parent_uid=reference_id_to_remove,
463
role=reference_id_to_remove_role,
464
)465
elif relation_type_ == "Child":
466
traceability_index.remove_requirement_child_uid(
467
requirement=requirement,
468
child_uid=reference_id_to_remove,
469
role=reference_id_to_remove_role,
470
)471
472
# Rendering back the Turbo template for each changed requirement.473
for reference_field in form_object.reference_fields:
474
if reference_field.field_type not in ("Parent", "Child"):
475
continue476
ref_uid = reference_field.field_value
477
478
node: SDocNode = traceability_index.graph_database.get_link_value(
479
link_type=GraphLinkType.UID_TO_NODE,
480
lhs_node=ref_uid,
481
)482
483
if node.get_document() == document:
484
action_object.this_document_requirements_to_update.add(node)
485
486
self._update_traceability_index_with_links_and_anchors(
487
requirement, existing_node_fields
488
)489
490
document.build_search_index()
491
492
traceability_index.update_last_updated()
493
494
new_title_is_none = requirement.reserved_title is None
495
496
# When a node's title presence changes (added or removed), the TOC497
# level numbers of all sibling nodes shift. Every node in the document498
# must be re-rendered so the browser sees the correct numbers.499
# Nodes whose title presence did not change do not trigger this: only500
# the edited node (already in the set) needs re-rendering.501
if old_title_is_none != new_title_is_none:
502
nodes_stack = list(document.section_contents)
503
while nodes_stack:
504
node_ = nodes_stack.pop()
505
if isinstance(node_, SDocNode):
506
action_object.this_document_requirements_to_update.add(
507
node_508
)509
if node_.section_contents is not None:
510
nodes_stack.extend(node_.section_contents)
511
512
return CreateOrUpdateNodeResult(
513
this_document_requirements_to_update=list(
514
action_object.this_document_requirements_to_update
515
),516
)517
518
def _update_traceability_index_with_links_and_anchors(
519
self, updated_node: SDocNode, existing_node_fields: List[SDocNodeField]
520
) -> None:
521
traceability_index = self.traceability_index
522
523
existing_anchor_uids: Set[str] = set()
524
existing_links: List[InlineLink] = []
525
for node_field_ in existing_node_fields:
526
for part_ in node_field_.parts:
527
if isinstance(part_, Anchor):
528
existing_anchor_uids.add(part_.value)
529
elif isinstance(part_, InlineLink):
530
existing_links.append(part_)
531
532
for existing_link_ in existing_links:
533
traceability_index.remove_inline_link(existing_link_)
534
535
for existing_anchor_uid in existing_anchor_uids:
536
traceability_index.remove_anchor_by_uid(existing_anchor_uid)
537
538
for node_field_ in updated_node.enumerate_fields():
539
for part_ in node_field_.parts:
540
if isinstance(part_, Anchor):
541
# Since this is a new section, we just need to register the542
# new anchor. By this time, we know that there is no543
# existing anchor with this name.544
traceability_index.update_with_anchor(part_)
545
elif isinstance(part_, InlineLink):
546
traceability_index.create_inline_link(part_)
547
548
def populate_node_fields_from_form_object(
549
self,
550
node: SDocNode,
551
form_object: RequirementFormObject,
552
map_form_to_requirement_fields: Dict[
553
RequirementFormField, Optional[FreeTextContainer]
554
],555
) -> None:
556
# FIXME: Leave only one method based on set_field_value().557
for form_field_name, form_fields in form_object.fields.items():
558
for form_field_index, form_field in enumerate(form_fields):
559
if form_field.field_type != RequirementFormFieldType.MULTILINE:
560
node.set_field_value(
561
field_name=form_field_name,
562
form_field_index=form_field_index,
563
value=form_field.field_value,
564
)565
continue566
567
free_text_content: Optional[FreeTextContainer] = (
568
map_form_to_requirement_fields[form_field]
569
)570
requirement_field: Optional[SDocNodeField] = (
571
SDocNodeField.from_parts(
572
node,
573
field_name=form_field_name,
574
parts=free_text_content.parts,
575
multiline=form_field.field_type
576
== RequirementFormFieldType.MULTILINE,
577
)578
if free_text_content is not None
579
else None
580
)581
node.set_field_value(
582
field_name=form_field_name,
583
form_field_index=form_field_index,
584
value=requirement_field,
585
)