StrictDoc Documentation
strictdoc/core/transforms/update_requirement.py
Source file coverage
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
@dataclass
59
class CreateNodeInfo:
60
    whereto: str
61
    requirement_mid: str
62
    reference_mid: str
63
 
64
 
65
@dataclass
66
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 includes
80
        # the requirement itself, all links it was linking to
81
        # (for deleted links) and all links it is linking to now
82
        # (including new links).
83
        self.this_document_requirements_to_update: Set[SDocNode] = set()
84
 
85
 
86
@dataclass
87
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
                    continue
136
                # Validate the field content using the writer that matches the
137
                # document's markup (RST by default, Markdown for Markdown
138
                # 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_container
171
                        )
172
                        if free_text_container is None:
173
                            continue
174
                        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
                                    break
194
                                this_node_unique_anchors.add(part.value)
195
                                anchors.append(part)
196
                            else:
197
                                pass
198
                        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
                                SingleValidationError
206
                            ) 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 from
235
            # 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_node
266
                    )
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 because
312
            #        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 updated
346
        # 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
            requirement
370
        )
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
                continue
408
            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
                pass
426
            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_document
446
            )
447
            # If a link was pointing towards a parent requirement in this
448
            # 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_requirement
452
                )
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
                continue
476
            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 TOC
497
        # level numbers of all sibling nodes shift. Every node in the document
498
        # must be re-rendered so the browser sees the correct numbers.
499
        # Nodes whose title presence did not change do not trigger this: only
500
        # 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 the
542
                    # new anchor. By this time, we know that there is no
543
                    # 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
                    continue
566
 
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
                )